feat(l4d2-web): server + overlay detail — live-refresh via HTMX, restructured

Vendors HTMX 2.0.4 (the prior file was a 1-line stub) and uses it to poll
two new partials on a 2s tick while a job is in flight:

- /servers/<id>/actions → state badge, filtered action buttons,
  last-job sentence, live job log (SSE) while a Start/Stop/Reset job
  is running. When the job is terminal the partial re-renders without
  hx-trigger and polling stops.
- /overlays/<id>/build-status → build state badge, last-build
  sentence, live job log while a build_overlay job is running. Same
  terminal-state stop behavior.

Server detail restructure:
- Editable name moves out of the page body into a Rename modal
  triggered from a link next to Delete in the page footer.
- Compact dl with Port (linked as steam://run/550//+connect <host>:<port>)
  and Blueprint.
- Actions row: state badge + state-filtered buttons (start/stop, reset)
  + last-job sentence. Drift warning when desired ≠ actual.
- Recent Jobs table removed.

Overlay detail restructure:
- Single panel, dl Type/Scope, no separate Last build row, no Builds
  section.
- Script form gets two compound submits: "Save and build" and
  "Save, reset and rebuild". Standalone Rebuild/Wipe gone.
- Build status state badge + last-build sentence under the editor;
  action buttons hide while a build is in flight.
- Rename modal in the page footer next to Delete.

sse.js binds on htmx:load (covers initial document and post-swap inserts)
and closes EventSources on htmx:beforeCleanupElement to avoid leaking
streams across swaps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-09 01:27:30 +02:00
parent 3c4bd6880a
commit fa686f11e3
No known key found for this signature in database
8 changed files with 444 additions and 184 deletions

View file

@ -101,6 +101,86 @@ def servers_page() -> str:
) )
_OPERATION_GERUND = {
"start": "starting",
"stop": "stopping",
"reset": "resetting",
"delete": "deleting",
"initialize": "initializing",
}
_TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"}
def _build_server_actions_context(db, server) -> dict:
from l4d2web.services.timeago import humanize_delta
latest_job = db.scalar(
select(Job)
.where(Job.server_id == server.id)
.order_by(Job.created_at.desc())
.limit(1)
)
if latest_job is not None:
db.expunge(latest_job)
actual_state = server.actual_state
desired_state = server.desired_state
active_operation = (
latest_job.operation
if latest_job is not None and latest_job.state not in _TERMINAL_JOB_STATES
else None
)
has_active_job = active_operation is not None
if has_active_job:
display_state = _OPERATION_GERUND.get(active_operation, active_operation) + ""
state_class = "state-transient"
elif actual_state == "running":
display_state = "running"
state_class = "state-running"
elif actual_state == "stopped":
display_state = "stopped"
state_class = "state-stopped"
else:
display_state = actual_state or "unknown"
state_class = "state-unknown"
visible_buttons: list[str] = []
if not has_active_job:
if actual_state == "running":
visible_buttons.append("stop")
else:
visible_buttons.append("start")
visible_buttons.append("reset")
drift = (not has_active_job) and desired_state != actual_state
latest_job_phrase: str | None = None
latest_job_when: str | None = None
latest_job_is_running = False
if latest_job is not None:
if latest_job.state in _TERMINAL_JOB_STATES:
latest_job_phrase = f"{latest_job.operation} {latest_job.state}"
ref_time = latest_job.finished_at or latest_job.created_at
else:
latest_job_phrase = _OPERATION_GERUND.get(latest_job.operation, latest_job.operation)
latest_job_is_running = True
ref_time = latest_job.started_at or latest_job.created_at
latest_job_when = humanize_delta(ref_time)
return {
"display_state": display_state,
"state_class": state_class,
"visible_buttons": visible_buttons,
"drift": drift,
"latest_job": latest_job,
"latest_job_phrase": latest_job_phrase,
"latest_job_when": latest_job_when,
"latest_job_is_running": latest_job_is_running,
}
@bp.get("/servers/<int:server_id>") @bp.get("/servers/<int:server_id>")
@require_login @require_login
def server_detail(server_id: int): def server_detail(server_id: int):
@ -112,23 +192,32 @@ def server_detail(server_id: int):
if server is None: if server is None:
return Response(status=404) return Response(status=404)
blueprint = db.scalar(select(BlueprintModel).where(BlueprintModel.id == server.blueprint_id)) blueprint = db.scalar(select(BlueprintModel).where(BlueprintModel.id == server.blueprint_id))
recent_job_rows = db.execute( ctx = _build_server_actions_context(db, server)
select(Job, User, Server)
.outerjoin(User, User.id == Job.user_id) connect_host = request.host.split(":")[0]
.outerjoin(Server, Server.id == Job.server_id)
.where(Job.server_id == server.id)
.order_by(Job.created_at.desc())
.limit(5)
).all()
return render_template( return render_template(
"server_detail.html", "server_detail.html",
server=server, server=server,
blueprint=blueprint, blueprint=blueprint,
recent_job_rows=recent_job_rows, connect_host=connect_host,
**ctx,
) )
@bp.get("/servers/<int:server_id>/actions")
@require_login
def server_actions_fragment(server_id: int):
user = current_user()
assert user is not None
with session_scope() as db:
server = db.scalar(select(Server).where(Server.id == server_id, Server.user_id == user.id))
if server is None:
return Response(status=404)
ctx = _build_server_actions_context(db, server)
return render_template("_server_actions.html", server=server, **ctx)
@bp.get("/servers/<int:server_id>/jobs") @bp.get("/servers/<int:server_id>/jobs")
@require_login @require_login
def server_jobs_page(server_id: int): def server_jobs_page(server_id: int):
@ -186,6 +275,58 @@ def overlay_jobs_page(overlay_id: int):
return render_template("overlay_jobs.html", overlay=overlay, rows=rows) return render_template("overlay_jobs.html", overlay=overlay, rows=rows)
_BUILD_STATE_LABELS = {
"ok": ("ok", "state-running"),
"failed": ("failed", "state-stopped"),
"": ("never built", "state-unknown"),
}
def _build_overlay_build_status_context(db, overlay) -> dict:
from l4d2web.services.timeago import humanize_delta
latest_build = db.scalar(
select(Job)
.where(Job.operation == "build_overlay", Job.overlay_id == overlay.id)
.order_by(Job.created_at.desc())
.limit(1)
)
if latest_build is not None:
db.expunge(latest_build)
is_running = (
latest_build is not None and latest_build.state not in _TERMINAL_JOB_STATES
)
if is_running:
build_state_label = "building…"
build_state_class = "state-transient"
else:
build_state_label, build_state_class = _BUILD_STATE_LABELS.get(
overlay.last_build_status or "", _BUILD_STATE_LABELS[""]
)
latest_build_phrase: str | None = None
latest_build_when: str | None = None
if latest_build is not None:
if latest_build.state in _TERMINAL_JOB_STATES:
latest_build_phrase = f"{latest_build.operation} {latest_build.state}"
ref_time = latest_build.finished_at or latest_build.created_at
else:
latest_build_phrase = "building"
ref_time = latest_build.started_at or latest_build.created_at
latest_build_when = humanize_delta(ref_time)
return {
"latest_build": latest_build,
"latest_build_is_running": is_running,
"latest_build_phrase": latest_build_phrase,
"latest_build_when": latest_build_when,
"build_state_label": build_state_label,
"build_state_class": build_state_class,
}
@bp.get("/overlays/<int:overlay_id>") @bp.get("/overlays/<int:overlay_id>")
@require_login @require_login
def overlay_detail(overlay_id: int): def overlay_detail(overlay_id: int):
@ -217,12 +358,7 @@ def overlay_detail(overlay_id: int):
.where(OverlayWorkshopItem.overlay_id == overlay.id) .where(OverlayWorkshopItem.overlay_id == overlay.id)
.order_by(WorkshopItem.created_at) .order_by(WorkshopItem.created_at)
).all() ).all()
latest_build_job = db.scalar( build_ctx = _build_overlay_build_status_context(db, overlay)
select(Job)
.where(Job.operation == "build_overlay", Job.overlay_id == overlay.id)
.order_by(Job.created_at.desc())
.limit(1)
)
file_tree_root_entries, file_tree_truncated_count = _root_file_tree(overlay) file_tree_root_entries, file_tree_truncated_count = _root_file_tree(overlay)
@ -231,15 +367,30 @@ def overlay_detail(overlay_id: int):
overlay=overlay, overlay=overlay,
using_blueprints=using_blueprints, using_blueprints=using_blueprints,
workshop_items=workshop_items, workshop_items=workshop_items,
latest_build_job=latest_build_job,
file_tree_root_entries=file_tree_root_entries, file_tree_root_entries=file_tree_root_entries,
file_tree_truncated=file_tree_truncated_count > 0 file_tree_truncated=file_tree_truncated_count > 0
if file_tree_root_entries is not None if file_tree_root_entries is not None
else False, else False,
file_tree_truncated_count=file_tree_truncated_count, file_tree_truncated_count=file_tree_truncated_count,
**build_ctx,
) )
@bp.get("/overlays/<int:overlay_id>/build-status")
@require_login
def overlay_build_status_fragment(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)
ctx = _build_overlay_build_status_context(db, overlay)
return render_template("_overlay_build_status.html", overlay=overlay, **ctx)
def _root_file_tree(overlay: Overlay) -> tuple[list[dict] | None, int]: def _root_file_tree(overlay: Overlay) -> tuple[list[dict] | None, int]:
"""Return (entries, truncated_count) for the overlay's runtime directory, """Return (entries, truncated_count) for the overlay's runtime directory,
or (None, 0) if the directory doesn't exist or the path is unresolvable or (None, 0) if the directory doesn't exist or the path is unresolvable
@ -290,6 +441,10 @@ def blueprint_page(blueprint_id: int):
select(BlueprintOverlay.overlay_id, BlueprintOverlay.position) select(BlueprintOverlay.overlay_id, BlueprintOverlay.position)
.where(BlueprintOverlay.blueprint_id == blueprint.id) .where(BlueprintOverlay.blueprint_id == blueprint.id)
).all() ).all()
expose_rows = db.execute(
select(BlueprintOverlay.overlay_id, BlueprintOverlay.expose_server_cfg)
.where(BlueprintOverlay.blueprint_id == blueprint.id)
).all()
all_overlays = db.scalars( all_overlays = db.scalars(
select(Overlay) select(Overlay)
.where(Overlay.user_id.is_(None) | (Overlay.user_id == user.id)) .where(Overlay.user_id.is_(None) | (Overlay.user_id == user.id))
@ -297,6 +452,7 @@ def blueprint_page(blueprint_id: int):
).all() ).all()
overlay_positions = {overlay_id: position + 1 for overlay_id, position in position_rows} overlay_positions = {overlay_id: position + 1 for overlay_id, position in position_rows}
overlay_expose_state = {overlay_id: bool(expose) for overlay_id, expose in expose_rows}
selected_ids = {overlay.id for overlay in selected_overlays} selected_ids = {overlay.id for overlay in selected_overlays}
available_overlays = [overlay for overlay in all_overlays if overlay.id not in selected_ids] available_overlays = [overlay for overlay in all_overlays if overlay.id not in selected_ids]
return render_template( return render_template(
@ -307,6 +463,7 @@ def blueprint_page(blueprint_id: int):
all_overlays=all_overlays, all_overlays=all_overlays,
selected_overlay_ids=selected_ids, selected_overlay_ids=selected_ids,
overlay_positions=overlay_positions, overlay_positions=overlay_positions,
overlay_expose_state=overlay_expose_state,
arguments=json.loads(blueprint.arguments), arguments=json.loads(blueprint.arguments),
config_lines=json.loads(blueprint.config), config_lines=json.loads(blueprint.config),
) )

View file

@ -1,10 +1,14 @@
function streamTextToElement(element) { function streamTextToElement(element) {
if (element.dataset.sseBound === "true") {
return;
}
const url = element.dataset.sseUrl; const url = element.dataset.sseUrl;
if (!url) { if (!url) {
return; return;
} }
const source = new EventSource(url); const source = new EventSource(url);
element._sseSource = source;
element.dataset.sseBound = "true";
const appendLine = (line) => { const appendLine = (line) => {
element.textContent += `${line}\n`; element.textContent += `${line}\n`;
@ -24,6 +28,37 @@ function streamTextToElement(element) {
}); });
} }
document.addEventListener("DOMContentLoaded", () => { function bindSseIn(root) {
document.querySelectorAll("[data-sse-url]").forEach(streamTextToElement); if (!root) return;
}); const scope = root.matches?.("[data-sse-url]") ? [root] : [];
if (root.querySelectorAll) {
root.querySelectorAll("[data-sse-url]").forEach((el) => scope.push(el));
}
scope.forEach(streamTextToElement);
}
function closeSseIn(root) {
if (!root) return;
const scope = root.matches?.("[data-sse-url]") ? [root] : [];
if (root.querySelectorAll) {
root.querySelectorAll("[data-sse-url]").forEach((el) => scope.push(el));
}
scope.forEach((el) => {
if (el._sseSource) {
el._sseSource.close();
el._sseSource = null;
delete el.dataset.sseBound;
}
});
}
document.addEventListener("DOMContentLoaded", () => bindSseIn(document));
// HTMX fires `htmx:load` for the initial document and after every swap, so
// dynamically inserted log-stream elements get bound. `htmx:beforeCleanupElement`
// fires for elements about to be removed; close their EventSources first to
// stop the previous stream and avoid leaking sockets.
document.addEventListener("htmx:load", (event) => bindSseIn(event.detail.elt));
document.addEventListener("htmx:beforeCleanupElement", (event) =>
closeSseIn(event.detail.elt),
);

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,18 @@
<div id="overlay-build-status"
{% if latest_build_is_running %}hx-get="/overlays/{{ overlay.id }}/build-status"
hx-trigger="every 2s" hx-swap="outerHTML"{% endif %}>
<div class="server-actions">
<span class="state-badge {{ build_state_class }}">{{ build_state_label }}</span>
</div>
{% if latest_build %}
<p class="last-job">
<a href="/jobs/{{ latest_build.id }}">{{ latest_build_phrase }}</a>
{% if latest_build_is_running %}since{% endif %}
{{ latest_build_when }}
(<a href="/overlays/{{ overlay.id }}/jobs">show all</a>)
</p>
{% endif %}
{% if latest_build_is_running %}
<pre class="log-stream job-log" data-sse-url="/jobs/{{ latest_build.id }}/stream"></pre>
{% endif %}
</div>

View file

@ -0,0 +1,36 @@
<div id="server-actions"
{% if latest_job_is_running %}hx-get="/servers/{{ server.id }}/actions"
hx-trigger="every 2s" hx-swap="outerHTML"{% endif %}>
<div class="server-actions">
<span class="state-badge {{ state_class }}">{{ display_state }}</span>
{% if 'start' in visible_buttons %}
<form method="post" action="/servers/{{ server.id }}/start" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button type="submit">start</button>
</form>
{% endif %}
{% if 'stop' in visible_buttons %}
<form method="post" action="/servers/{{ server.id }}/stop" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button type="submit">stop</button>
</form>
{% endif %}
{% if 'reset' in visible_buttons %}
<button type="button" class="danger" data-modal-open="reset-server-modal">reset</button>
{% endif %}
</div>
{% if drift %}
<p class="state-drift"><strong>Warning:</strong> server is {{ server.actual_state }} but requested state is {{ server.desired_state }}.</p>
{% endif %}
{% if latest_job %}
<p class="last-job">
<a href="/jobs/{{ latest_job.id }}">{{ latest_job_phrase }}</a>
{% if latest_job_is_running %}since{% endif %}
{{ latest_job_when }}
(<a href="/servers/{{ server.id }}/jobs">show all</a>)
</p>
{% endif %}
{% if latest_job_is_running %}
<pre class="log-stream job-log" data-sse-url="/jobs/{{ latest_job.id }}/stream"></pre>
{% endif %}
</div>

View file

@ -3,61 +3,19 @@
{% block title %}Overlay {{ overlay.name }} | left4me{% endblock %} {% block title %}Overlay {{ overlay.name }} | left4me{% endblock %}
{% block content %} {% block content %}
{% set can_edit = g.user.admin or (overlay.type in ['workshop', 'script'] and overlay.user_id == g.user.id) %}
<section class="panel"> <section class="panel">
<div class="page-heading"> <div class="page-heading">
<h1>Overlay: {{ overlay.name }}</h1> <h1>Overlay: {{ overlay.name }}</h1>
{% 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 %}
</div> </div>
{% if can_edit %} <dl class="server-info">
<form method="post" action="/overlays/{{ overlay.id }}" class="stack"> <div><dt>Type</dt><dd>{{ overlay.type }}</dd></div>
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <div><dt>Scope</dt><dd>{% if overlay.user_id %}private{% else %}system{% endif %}</dd></div>
<label>Name <input name="name" value="{{ overlay.name }}" required></label> </dl>
<div>
<button type="submit">Save</button>
</div>
</form>
{% endif %}
<table class="definition-table">
<tbody>
<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 overlay.type == 'script' %}
<section class="panel">
<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 overlay.type == 'script' %}
<h2 class="section-title">Script</h2>
{% if can_edit %} {% if can_edit %}
<form method="post" action="/overlays/{{ overlay.id }}/script" class="stack"> <form method="post" action="/overlays/{{ overlay.id }}/script" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
@ -65,38 +23,22 @@
<textarea name="script" rows="20" spellcheck="false">{{ overlay.script or "" }}</textarea> <textarea name="script" rows="20" spellcheck="false">{{ overlay.script or "" }}</textarea>
</label> </label>
<p class="muted">Runs sandboxed against the overlay directory mounted at <code>/overlay</code>.</p> <p class="muted">Runs sandboxed against the overlay directory mounted at <code>/overlay</code>.</p>
<div> {% if not latest_build_is_running %}
<button type="submit">Save and build</button> <div class="form-actions-inline">
<button type="submit" name="action" value="save_build">Save and build</button>
<button type="submit" name="action" value="save_reset_build">Save, reset and rebuild</button>
</div> </div>
{% endif %}
</form> </form>
{% else %} {% else %}
<pre class="script-preview">{{ overlay.script or "" }}</pre> <pre class="script-preview">{{ overlay.script or "" }}</pre>
{% endif %} {% endif %}
{% include "_overlay_build_status.html" %}
<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>
·
{% endif %} {% endif %}
<a href="/overlays/{{ overlay.id }}/jobs">all builds →</a>
</p>
</section>
{% endif %}
{% if overlay.type == 'workshop' %} {% if overlay.type == 'workshop' %}
<section class="panel"> <h2 class="section-title">Workshop items</h2>
<div class="page-heading"> {% if can_edit and not latest_build_is_running %}
<h2>Workshop items</h2>
{% if can_edit %}
<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>
{% endif %}
</div>
{% if can_edit %}
<form method="post" action="/overlays/{{ overlay.id }}/items" class="stack"> <form method="post" action="/overlays/{{ overlay.id }}/items" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<fieldset class="workshop-input-mode"> <fieldset class="workshop-input-mode">
@ -105,32 +47,18 @@
<label><input type="radio" name="input_mode" value="collection"> Collection (one ID or URL)</label> <label><input type="radio" name="input_mode" value="collection"> Collection (one ID or URL)</label>
</fieldset> </fieldset>
<label>Workshop input <textarea name="input" rows="3" placeholder="123456789&#10;https://steamcommunity.com/sharedfiles/filedetails/?id=987654321"></textarea></label> <label>Workshop input <textarea name="input" rows="3" placeholder="123456789&#10;https://steamcommunity.com/sharedfiles/filedetails/?id=987654321"></textarea></label>
<div>
<button type="submit">Add</button> <button type="submit">Add</button>
</div>
</form> </form>
{% endif %} {% endif %}
<div id="overlay-item-table"> <div id="overlay-item-table">
{% include "_overlay_item_table.html" with context %} {% include "_overlay_item_table.html" with context %}
</div> </div>
</section>
<section class="panel"> {% include "_overlay_build_status.html" %}
<h2>Builds</h2>
<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>
·
{% endif %} {% endif %}
<a href="/overlays/{{ overlay.id }}/jobs">all builds →</a>
</p>
</section>
{% endif %}
<section class="panel"> <h2 class="section-title">Files</h2>
<h2>Files</h2>
{% if not file_tree_root_entries %} {% if not file_tree_root_entries %}
<p class="muted">No files yet — build this overlay to populate it.</p> <p class="muted">No files yet — build this overlay to populate it.</p>
{% else %} {% else %}
@ -139,10 +67,8 @@
{% set truncated_count = file_tree_truncated_count %} {% set truncated_count = file_tree_truncated_count %}
{% include "_overlay_file_tree.html" %} {% include "_overlay_file_tree.html" %}
{% endif %} {% endif %}
</section>
<section class="panel"> <h2 class="section-title">Used by</h2>
<h2>Used by</h2>
{% if using_blueprints %} {% if using_blueprints %}
<ul class="used-by-list"> <ul class="used-by-list">
{% for blueprint in using_blueprints %} {% for blueprint in using_blueprints %}
@ -155,6 +81,25 @@
</section> </section>
{% if can_edit %} {% if can_edit %}
<div class="page-footer-actions">
<button type="button" class="danger-outline" data-modal-open="delete-overlay-modal">Delete overlay</button>
<a href="#" class="link-button" data-modal-open="rename-overlay-modal">Rename</a>
</div>
<dialog id="rename-overlay-modal" class="modal" aria-labelledby="rename-overlay-title">
<div class="modal-header">
<h2 id="rename-overlay-title">Rename overlay</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<form method="post" action="/overlays/{{ overlay.id }}" class="inline-save">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<input name="name" value="{{ overlay.name }}" required autofocus>
<button type="submit">Save</button>
</form>
</div>
</dialog>
<dialog id="delete-overlay-modal" class="modal" aria-labelledby="delete-overlay-title"> <dialog id="delete-overlay-modal" class="modal" aria-labelledby="delete-overlay-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="delete-overlay-title">Delete overlay "{{ overlay.name }}"?</h2> <h2 id="delete-overlay-title">Delete overlay "{{ overlay.name }}"?</h2>
@ -171,24 +116,5 @@
</form> </form>
</div> </div>
</dialog> </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 %} {% endif %}
{% endblock %} {% endblock %}

View file

@ -6,48 +6,39 @@
<section class="panel"> <section class="panel">
<div class="page-heading"> <div class="page-heading">
<h1>Server: {{ server.name }}</h1> <h1>Server: {{ server.name }}</h1>
<div class="button-row">
{% for operation in ["initialize", "start", "stop"] %}
<form method="post" action="/servers/{{ server.id }}/{{ operation }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button type="submit">{{ operation }}</button>
</form>
{% endfor %}
<button type="button" class="danger" data-modal-open="reset-server-modal">reset</button>
<button type="button" class="danger" data-modal-open="delete-server-modal">delete</button>
</div>
</div> </div>
<table class="definition-table"> <dl class="server-info">
<tbody> <div><dt>Port</dt><dd><a href="steam://run/550//+connect%20{{ connect_host }}:{{ server.port }}">{{ server.port }}</a></dd></div>
<tr><th>Name</th><td>{{ server.name }}</td></tr> <div><dt>Blueprint</dt><dd>{% if blueprint %}<a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a>{% endif %}</dd></div>
<tr><th>Port</th><td>{{ server.port }}</td></tr> </dl>
<tr><th>Blueprint</th><td>{% if blueprint %}<a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a>{% endif %}</td></tr>
<tr><th>Desired state</th><td>{{ server.desired_state }}</td></tr>
<tr><th>Actual state</th><td>{{ server.actual_state }}</td></tr>
<tr><th>Last error</th><td>{{ server.last_error or "-" }}</td></tr>
</tbody>
</table>
</section>
<section class="panel"> <h2 class="section-title">Actions</h2>
<div class="page-heading"> {% include "_server_actions.html" %}
<h2>Recent Jobs</h2>
<a href="/servers/{{ server.id }}/jobs">View all jobs</a>
</div>
{% set rows = recent_job_rows %}
{% set show_user = false %}
{% set show_server = false %}
{% set show_cancel = true %}
{% set cancel_next = "/servers/" ~ server.id %}
{% include "_job_table.html" %}
</section>
<section class="panel"> <h2 class="section-title">Server Log</h2>
<h2>Server Log</h2>
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre> <pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
</section> </section>
<div class="page-footer-actions">
<button type="button" class="danger-outline" data-modal-open="delete-server-modal">Delete server</button>
<a href="#" class="link-button" data-modal-open="rename-server-modal">Rename</a>
</div>
<dialog id="rename-server-modal" class="modal" aria-labelledby="rename-server-title">
<div class="modal-header">
<h2 id="rename-server-title">Rename server</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<form method="post" action="/servers/{{ server.id }}" class="inline-save">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<input name="name" value="{{ server.name }}" required autofocus>
<button type="submit">Save</button>
</form>
</div>
</dialog>
<dialog id="reset-server-modal" class="modal" aria-labelledby="reset-server-title"> <dialog id="reset-server-modal" class="modal" aria-labelledby="reset-server-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="reset-server-title">Reset server "{{ server.name }}"?</h2> <h2 id="reset-server-title">Reset server "{{ server.name }}"?</h2>

View file

@ -129,17 +129,50 @@ def test_server_detail_shows_operations_and_logs(auth_client_with_server) -> Non
assert response.status_code == 200 assert response.status_code == 200
assert "Server: alpha" in text assert "Server: alpha" in text
# Default actual_state is "unknown", no active job → only `start` and modal `delete`.
assert 'action="/servers/1/start"' in text assert 'action="/servers/1/start"' in text
assert 'action="/servers/1/stop"' in text assert 'action="/servers/1/stop"' not in text
assert 'action="/servers/1/initialize"' in text assert 'action="/servers/1/initialize"' not in text # UI dropped this; start auto-inits.
assert 'action="/servers/1/delete"' in text assert 'action="/servers/1/delete"' in text # inside the delete modal
assert 'href="/blueprints/1"' in text assert 'href="/blueprints/1"' in text
assert "<h2>Blueprint</h2>" not in text
assert "standard" not in text
assert 'data-sse-url="/servers/1/logs/stream"' in text assert 'data-sse-url="/servers/1/logs/stream"' in text
# Steam launch-and-connect link (run + appid 550 = L4D2)
assert 'href="steam://run/550//+connect%20' in text
def test_server_detail_shows_recent_jobs(auth_client_with_server) -> None: def test_server_actions_fragment_polls_while_running(auth_client_with_server) -> None:
with session_scope() as session:
job = Job(user_id=1, server_id=1, operation="start", state="queued")
session.add(job)
session.flush()
job_id = job.id
response = auth_client_with_server.get("/servers/1/actions")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert 'id="server-actions"' in text
assert "<html" not in text # fragment, not a full page
assert 'hx-trigger="every 2s"' in text
assert f'data-sse-url="/jobs/{job_id}/stream"' in text
def test_server_actions_fragment_settles_when_terminal(auth_client_with_server) -> None:
with session_scope() as session:
session.add(Job(user_id=1, server_id=1, operation="start", state="succeeded"))
response = auth_client_with_server.get("/servers/1/actions")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert 'id="server-actions"' in text
# No polling once the latest job is terminal.
assert "hx-trigger" not in text
# No live job-log streaming either.
assert "data-sse-url=\"/jobs/" not in text
def test_server_detail_shows_last_job_summary(auth_client_with_server) -> None:
with session_scope() as session: with session_scope() as session:
job = Job(user_id=1, server_id=1, operation="start", state="queued") job = Job(user_id=1, server_id=1, operation="start", state="queued")
session.add(job) session.add(job)
@ -150,10 +183,13 @@ def test_server_detail_shows_recent_jobs(auth_client_with_server) -> None:
text = response.get_data(as_text=True) text = response.get_data(as_text=True)
assert response.status_code == 200 assert response.status_code == 200
assert "Recent Jobs" in text # Active job → no Start/Stop/Reset, only the last-job sentence.
assert 'href="/servers/1/jobs"' in text assert 'action="/servers/1/start"' not in text
assert f'href="/jobs/{job_id}"' in text assert f'href="/jobs/{job_id}"' in text
assert 'action="/jobs/' in text assert 'href="/servers/1/jobs"' in text # "show all" link
assert "starting" in text # gerund form for in-flight start
# Recent Jobs table is gone.
assert "Recent Jobs" not in text
def test_server_jobs_page_lists_server_jobs(auth_client_with_server) -> None: def test_server_jobs_page_lists_server_jobs(auth_client_with_server) -> None:
@ -455,7 +491,7 @@ def test_blueprint_detail_has_ordered_overlay_form(auth_client_with_server) -> N
text = response.get_data(as_text=True) text = response.get_data(as_text=True)
assert response.status_code == 200 assert response.status_code == 200
assert "Overlay order matters" in text assert ">Overlays<" in text
assert 'name="arguments"' in text assert 'name="arguments"' in text
assert 'name="config"' in text assert 'name="config"' in text
assert 'name="overlay_ids"' in text assert 'name="overlay_ids"' in text
@ -545,9 +581,66 @@ def test_overlay_detail_script_section(auth_client_with_server) -> None:
assert response.status_code == 200 assert response.status_code == 200
assert '<textarea name="script"' in text assert '<textarea name="script"' in text
assert "Rebuild" in text # Two compound submits replaced standalone Rebuild/Wipe.
assert "Wipe" in text assert "Save and build" in text
assert "Last build" in text assert "Save, reset and rebuild" in text
assert "Rebuild" not in text # no standalone Rebuild button
assert "Last build" not in text # state-badge replaces this label
# Build-status badge is rendered (initial state for a fresh overlay).
assert "never built" in text
def test_overlay_build_status_fragment_polls_while_running(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("polling", "script", user_id)
with session_scope() as s:
job = Job(user_id=user_id, overlay_id=overlay_id, operation="build_overlay", state="running")
s.add(job)
s.flush()
job_id = job.id
response = auth_client_with_server.get(f"/overlays/{overlay_id}/build-status")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert 'id="overlay-build-status"' in text
assert "<html" not in text # fragment, not a full page
assert 'hx-trigger="every 2s"' in text
assert f'data-sse-url="/jobs/{job_id}/stream"' in text
def test_overlay_build_status_fragment_settles_when_terminal(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("settled", "script", user_id)
with session_scope() as s:
s.add(Job(user_id=user_id, overlay_id=overlay_id, operation="build_overlay", state="succeeded"))
response = auth_client_with_server.get(f"/overlays/{overlay_id}/build-status")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "hx-trigger" not in text
assert "data-sse-url=\"/jobs/" not in text
def test_overlay_action_buttons_hidden_during_build(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("inflight", "script", user_id)
with session_scope() as s:
s.add(Job(user_id=user_id, overlay_id=overlay_id, operation="build_overlay", state="running"))
response = auth_client_with_server.get(f"/overlays/{overlay_id}")
text = response.get_data(as_text=True)
assert response.status_code == 200
# Submit buttons hidden while a build is in flight.
assert "Save and build" not in text
assert "Save, reset and rebuild" not in text
# Status partial renders the building badge.
assert "building…" in text
def test_overlay_detail_workshop_section_unchanged(auth_client_with_server) -> None: def test_overlay_detail_workshop_section_unchanged(auth_client_with_server) -> None:
@ -566,6 +659,10 @@ def test_overlay_detail_links_to_overlay_jobs_page(auth_client_with_server) -> N
with session_scope() as s: with session_scope() as s:
user_id = s.query(User).filter_by(username="alice").one().id user_id = s.query(User).filter_by(username="alice").one().id
overlay_id = _seed_overlay("scripted", "script", user_id) overlay_id = _seed_overlay("scripted", "script", user_id)
# The "show all" link only appears once at least one build has happened
# (otherwise the build-status partial has nothing to summarize).
with session_scope() as s:
s.add(Job(user_id=user_id, overlay_id=overlay_id, operation="build_overlay", state="succeeded"))
response = auth_client_with_server.get(f"/overlays/{overlay_id}") response = auth_client_with_server.get(f"/overlays/{overlay_id}")
text = response.get_data(as_text=True) text = response.get_data(as_text=True)