feat(l4d2-web): per-overlay job list + redirect to job after build-triggering edits

Saving a script overlay or adding/removing workshop items now redirects to the
enqueued build job's detail page so logs are immediately visible. Added a new
/overlays/<id>/jobs page (linked as "all builds →" from the overlay detail
page) for browsing the full build history. Renamed the script "Save" button to
"Save and build" to make the side effect explicit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-08 17:44:22 +02:00
parent 5e2c771276
commit fb3c6be052
No known key found for this signature in database
8 changed files with 137 additions and 46 deletions

View file

@ -155,8 +155,9 @@ def update_script(overlay_id: int) -> Response:
if err is not None:
return err
overlay.script = script_text
enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id)
return redirect(f"/overlays/{overlay_id}")
job = enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id)
job_id = job.id
return redirect(f"/jobs/{job_id}")
@bp.post("/overlays/<int:overlay_id>/build")

View file

@ -161,6 +161,27 @@ def overlays() -> str:
return render_template("overlays.html", overlays=overlays)
@bp.get("/overlays/<int:overlay_id>/jobs")
@require_login
def overlay_jobs_page(overlay_id: int):
user = current_user()
assert user is not None
with session_scope() as db:
overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id))
if overlay is None:
return Response(status=404)
if not user.admin and overlay.user_id is not None and overlay.user_id != user.id:
return Response(status=403)
rows = db.execute(
select(Job, User, Server)
.outerjoin(User, User.id == Job.user_id)
.outerjoin(Server, Server.id == Job.server_id)
.where(Job.operation == "build_overlay", Job.overlay_id == overlay.id)
.order_by(Job.created_at.desc())
).all()
return render_template("overlay_jobs.html", overlay=overlay, rows=rows)
@bp.get("/overlays/<int:overlay_id>")
@require_login
def overlay_detail(overlay_id: int):

View file

@ -2,7 +2,7 @@
admin global refresh)."""
from __future__ import annotations
from flask import Blueprint, Response, redirect, render_template, request
from flask import Blueprint, Response, redirect, request
from sqlalchemy import delete as sa_delete
from sqlalchemy import select
@ -32,30 +32,6 @@ def _check_workshop_overlay_access(overlay_id: int, user, db):
return overlay, None
def _render_item_table(overlay_id: int):
with session_scope() as db:
overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id))
items = db.scalars(
select(WorkshopItem)
.join(
OverlayWorkshopItem,
OverlayWorkshopItem.workshop_item_id == WorkshopItem.id,
)
.where(OverlayWorkshopItem.overlay_id == overlay_id)
.order_by(WorkshopItem.created_at)
).all()
# Detach so attributes survive after the session closes.
for item in items:
db.expunge(item)
if overlay is not None:
db.expunge(overlay)
return render_template(
"_overlay_item_table.html",
overlay=overlay,
workshop_items=items,
)
@bp.post("/overlays/<int:overlay_id>/items")
@require_login
def add_items(overlay_id: int) -> Response:
@ -116,9 +92,10 @@ def add_items(overlay_id: int) -> Response:
if existing is None:
db.add(OverlayWorkshopItem(overlay_id=overlay_id, workshop_item_id=wi.id))
enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id)
job = enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id)
job_id = job.id
return _render_item_table(overlay_id)
return redirect(f"/jobs/{job_id}")
@bp.post("/overlays/<int:overlay_id>/items/<int:item_id>/delete")
@ -138,8 +115,9 @@ def remove_item(overlay_id: int, item_id: int) -> Response:
)
if result.rowcount == 0:
return Response(status=404)
enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id)
return _render_item_table(overlay_id)
job = enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id)
job_id = job.id
return redirect(f"/jobs/{job_id}")
@bp.post("/admin/workshop/refresh")

View file

@ -64,21 +64,23 @@
<label>Bash script
<textarea name="script" rows="20" spellcheck="false">{{ overlay.script or "" }}</textarea>
</label>
<p class="muted">Runs sandboxed against the overlay directory mounted at <code>/overlay</code>. Saving auto-enqueues a build.</p>
<p class="muted">Runs sandboxed against the overlay directory mounted at <code>/overlay</code>.</p>
<div>
<button type="submit">Save</button>
<button type="submit">Save and build</button>
</div>
</form>
{% else %}
<pre class="script-preview">{{ overlay.script or "" }}</pre>
{% endif %}
{% if latest_build_job %}
<p>
{% if latest_build_job %}
Latest build: <a href="/jobs/{{ latest_build_job.id }}">job #{{ latest_build_job.id }}</a>
— state: <strong>{{ latest_build_job.state }}</strong>
</p>
·
{% endif %}
<a href="/overlays/{{ overlay.id }}/jobs">all builds →</a>
</p>
</section>
{% endif %}
@ -114,16 +116,18 @@
</div>
</section>
{% if latest_build_job %}
<section class="panel">
<h2>Latest build</h2>
<h2>Builds</h2>
<p>
<a href="/jobs/{{ latest_build_job.id }}">job #{{ latest_build_job.id }}</a>
{% if latest_build_job %}
Latest build: <a href="/jobs/{{ latest_build_job.id }}">job #{{ latest_build_job.id }}</a>
— state: <strong>{{ latest_build_job.state }}</strong>
·
{% endif %}
<a href="/overlays/{{ overlay.id }}/jobs">all builds →</a>
</p>
</section>
{% endif %}
{% endif %}
<section class="panel">
<h2>Used by</h2>

View file

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Build jobs for {{ overlay.name }} | left4me{% endblock %}
{% block content %}
<section class="panel">
<div class="page-heading">
<h1>Build jobs for {{ overlay.name }}</h1>
<a href="/overlays/{{ overlay.id }}">Back to overlay</a>
</div>
{% set show_user = true %}
{% set show_server = false %}
{% set show_cancel = true %}
{% set cancel_next = "/overlays/" ~ overlay.id ~ "/jobs" %}
{% include "_job_table.html" %}
</section>
{% endblock %}

View file

@ -561,6 +561,69 @@ def test_overlay_detail_workshop_section_unchanged(auth_client_with_server) -> N
assert "Workshop items" in text
def test_overlay_detail_links_to_overlay_jobs_page(auth_client_with_server) -> None:
with session_scope() as s:
user_id = s.query(User).filter_by(username="alice").one().id
overlay_id = _seed_overlay("scripted", "script", user_id)
response = auth_client_with_server.get(f"/overlays/{overlay_id}")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert f'href="/overlays/{overlay_id}/jobs"' in text
assert "Save and build" in text
def test_overlay_jobs_page_lists_overlay_builds(auth_client_with_server) -> None:
with session_scope() as s:
user_id = s.query(User).filter_by(username="alice").one().id
overlay_id = _seed_overlay("scripted", "script", user_id)
other_overlay_id = _seed_overlay("other", "script", user_id)
with session_scope() as session:
session.add(Job(user_id=user_id, overlay_id=overlay_id, operation="build_overlay", state="succeeded"))
session.add(Job(user_id=user_id, overlay_id=overlay_id, operation="build_overlay", state="queued"))
# Job for a different overlay must not appear.
session.add(Job(user_id=user_id, overlay_id=other_overlay_id, operation="build_overlay", state="succeeded"))
response = auth_client_with_server.get(f"/overlays/{overlay_id}/jobs")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "Build jobs for scripted" in text
assert text.count("build_overlay") == 2
assert f'href="/overlays/{overlay_id}"' in text
def test_overlay_jobs_page_404_for_unknown_overlay(auth_client_with_server) -> None:
response = auth_client_with_server.get("/overlays/9999/jobs")
assert response.status_code == 404
def test_overlay_jobs_page_403_for_other_users_private_overlay(tmp_path, monkeypatch) -> None:
db_url = f"sqlite:///{tmp_path/'overlay-jobs-403.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
with session_scope() as session:
owner = User(username="owner", password_digest=hash_password("x"), admin=False)
other = User(username="other", password_digest=hash_password("x"), admin=False)
session.add_all([owner, other])
session.flush()
owner_id = owner.id
other_id = other.id
overlay_id = _seed_overlay("private", "script", owner_id)
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = other_id
response = client.get(f"/overlays/{overlay_id}/jobs")
assert response.status_code == 403
def test_overlay_detail_no_global_source_block(auth_client_with_server) -> None:
with session_scope() as s:
user_id = s.query(User).filter_by(username="alice").one().id

View file

@ -121,11 +121,13 @@ def test_update_script_body_enqueues_build(app, alice_id) -> None:
headers={"X-CSRF-Token": "test-token"},
)
assert r1.status_code == 302
assert r1.headers["Location"].startswith("/jobs/")
with session_scope() as s:
overlay = s.query(Overlay).filter_by(id=overlay_id).one()
assert overlay.script == "echo new"
jobs = s.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all()
assert len(jobs) == 1
assert r1.headers["Location"] == f"/jobs/{jobs[0].id}"
# Coalesce against pending.
r2 = client.post(
@ -134,6 +136,7 @@ def test_update_script_body_enqueues_build(app, alice_id) -> None:
headers={"X-CSRF-Token": "test-token"},
)
assert r2.status_code == 302
assert r2.headers["Location"] == r1.headers["Location"]
with session_scope() as s:
jobs = s.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all()
assert len(jobs) == 1

View file

@ -93,8 +93,8 @@ def test_add_single_item_creates_association_and_enqueues_build(overlay_for):
data={"input": "1001", "input_mode": "items"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 200
assert b"1001" in response.data
assert response.status_code == 302
assert response.headers["Location"].startswith("/jobs/")
with session_scope() as session:
n_assoc = session.query(OverlayWorkshopItem).count()
@ -106,6 +106,7 @@ def test_add_single_item_creates_association_and_enqueues_build(overlay_for):
jobs = session.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all()
assert len(jobs) == 1
assert jobs[0].state == "queued"
assert response.headers["Location"] == f"/jobs/{jobs[0].id}"
def test_add_multiline_batch_coalesces_into_one_build_job(overlay_for):
@ -118,7 +119,8 @@ def test_add_multiline_batch_coalesces_into_one_build_job(overlay_for):
data={"input": "1001\n1002\n1003", "input_mode": "items"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 200
assert response.status_code == 302
assert response.headers["Location"].startswith("/jobs/")
with session_scope() as session:
assert session.query(OverlayWorkshopItem).count() == 3
@ -137,7 +139,8 @@ def test_add_collection_resolves_members(overlay_for):
data={"input": "555", "input_mode": "collection"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 200
assert response.status_code == 302
assert response.headers["Location"].startswith("/jobs/")
resolve.assert_called_once_with("555")
with session_scope() as session:
@ -175,7 +178,7 @@ def test_add_duplicate_item_does_not_500(overlay_for):
data={"input": "1001", "input_mode": "items"},
headers={"X-CSRF-Token": "test-token"},
)
assert first.status_code == 200
assert first.status_code == 302
with _patch_steam([_meta("1001")]):
second = user_client.post(
@ -183,7 +186,7 @@ def test_add_duplicate_item_does_not_500(overlay_for):
data={"input": "1001", "input_mode": "items"},
headers={"X-CSRF-Token": "test-token"},
)
assert second.status_code == 200
assert second.status_code == 302
with session_scope() as session:
assert session.query(OverlayWorkshopItem).count() == 1
@ -208,7 +211,8 @@ def test_remove_item_drops_association_and_enqueues_rebuild(overlay_for):
f"/overlays/{overlay_id}/items/{item_id}/delete",
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 200
assert response.status_code == 302
assert response.headers["Location"].startswith("/jobs/")
with session_scope() as session:
assert session.query(OverlayWorkshopItem).count() == 0