feat(l4d2-web): script overlay UI

Adds the script type to the create-overlay modal (with an admin-only
system-wide checkbox) and a script-section to the detail page: textarea
for the bash body, Save / Rebuild / Wipe buttons, last_build_status
badge, latest-build-job link, and a Wipe confirm modal. Removes the
GlobalOverlaySource block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-08 15:50:36 +02:00
parent be22744d54
commit d351bcbee5
No known key found for this signature in database
3 changed files with 129 additions and 11 deletions

View file

@ -6,7 +6,7 @@
<section class="panel">
<div class="page-heading">
<h1>Overlay: {{ overlay.name }}</h1>
{% set can_edit = overlay.type not in ['l4d2center_maps', 'cedapug_maps'] and (g.user.admin or (overlay.type == 'workshop' and overlay.user_id == g.user.id)) %}
{% set can_edit = g.user.admin or (overlay.type in ['workshop', 'script'] and overlay.user_id == g.user.id) %}
{% if can_edit %}
<button type="button" class="danger" data-modal-open="delete-overlay-modal">Delete</button>
{% endif %}
@ -27,21 +27,58 @@
<tr><th>Type</th><td>{{ overlay.type }}</td></tr>
<tr><th>Scope</th><td>{% if overlay.user_id %}private{% else %}system{% endif %}</td></tr>
<tr><th>Path</th><td class="muted">{{ overlay.path }}</td></tr>
<tr>
<th>Last build</th>
<td>
{% if overlay.last_build_status == 'ok' %}
<span class="badge badge-ok">ok</span>
{% elif overlay.last_build_status == 'failed' %}
<span class="badge badge-error">failed</span>
{% else %}
<span class="badge badge-muted">never</span>
{% endif %}
</td>
</tr>
</tbody>
</table>
</section>
{% if global_source %}
{% if overlay.type == 'script' %}
<section class="panel">
<h2>Global source</h2>
<table class="definition-table">
<tbody>
<tr><th>Source key</th><td>{{ global_source.source_key }}</td></tr>
<tr><th>Source URL</th><td><a href="{{ global_source.source_url }}">{{ global_source.source_url }}</a></td></tr>
<tr><th>Last refreshed</th><td>{{ global_source.last_refreshed_at or "Never" }}</td></tr>
<tr><th>Last error</th><td>{{ global_source.last_error or "None" }}</td></tr>
</tbody>
</table>
<div class="page-heading">
<h2>Script</h2>
{% if can_edit %}
<div class="inline-form-group">
<form method="post" action="/overlays/{{ overlay.id }}/build" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button type="submit" class="button-secondary">Rebuild</button>
</form>
<button type="button" class="danger" data-modal-open="wipe-overlay-modal">Wipe</button>
</div>
{% endif %}
</div>
{% if can_edit %}
<form method="post" action="/overlays/{{ overlay.id }}/script" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<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>
<div>
<button type="submit">Save</button>
</div>
</form>
{% else %}
<pre class="script-preview">{{ overlay.script or "" }}</pre>
{% endif %}
{% if latest_build_job %}
<p>
Latest build: <a href="/jobs/{{ latest_build_job.id }}">job #{{ latest_build_job.id }}</a>
— state: <strong>{{ latest_build_job.state }}</strong>
</p>
{% endif %}
</section>
{% endif %}
@ -118,5 +155,24 @@
</form>
</div>
</dialog>
{% if overlay.type == 'script' %}
<dialog id="wipe-overlay-modal" class="modal" aria-labelledby="wipe-overlay-title">
<div class="modal-header">
<h2 id="wipe-overlay-title">Wipe overlay "{{ overlay.name }}"?</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<p>Empties the overlay directory. Use Rebuild afterward to repopulate.</p>
</div>
<div class="modal-footer">
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
<form method="post" action="/overlays/{{ overlay.id }}/wipe" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">Wipe</button>
</form>
</div>
</dialog>
{% endif %}
{% endif %}
{% endblock %}

View file

@ -37,8 +37,12 @@
<fieldset class="overlay-type-radio">
<legend>Type</legend>
<label><input type="radio" name="type" value="workshop" checked> Workshop (downloads from Steam)</label>
<label><input type="radio" name="type" value="script"> Script (runs sandboxed bash)</label>
</fieldset>
<label>Name <input name="name" required></label>
{% if g.user and g.user.admin %}
<label><input type="checkbox" name="system_wide" value="1"> System-wide (visible to all users)</label>
{% endif %}
<p class="muted">The path is generated automatically.</p>
</div>
<div class="modal-footer">

View file

@ -510,3 +510,61 @@ def test_non_admin_cannot_view_system_job(tmp_path, monkeypatch) -> None:
response = user_client.get(f"/jobs/{job_id}")
assert response.status_code == 403
def test_overlay_create_modal_offers_script_type(auth_client_with_server) -> None:
response = auth_client_with_server.get("/overlays")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert 'value="workshop"' in text
assert 'value="script"' in text
def _seed_overlay(name: str, type_: str, user_id: int) -> int:
with session_scope() as s:
overlay = Overlay(name=name, path="", type=type_, user_id=user_id)
s.add(overlay)
s.flush()
overlay.path = str(overlay.id)
s.flush()
return overlay.id
def test_overlay_detail_script_section(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("build", "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 '<textarea name="script"' in text
assert "Rebuild" in text
assert "Wipe" in text
assert "Last build" in text
def test_overlay_detail_workshop_section_unchanged(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("ws", "workshop", 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 "Workshop items" in text
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
overlay_id = _seed_overlay("ws", "workshop", user_id)
response = auth_client_with_server.get(f"/overlays/{overlay_id}")
text = response.get_data(as_text=True)
assert "Global source" not in text
assert "source_url" not in text