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:
parent
3c4bd6880a
commit
fa686f11e3
8 changed files with 444 additions and 184 deletions
|
|
@ -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>")
|
||||
@require_login
|
||||
def server_detail(server_id: int):
|
||||
|
|
@ -112,23 +192,32 @@ def server_detail(server_id: int):
|
|||
if server is None:
|
||||
return Response(status=404)
|
||||
blueprint = db.scalar(select(BlueprintModel).where(BlueprintModel.id == server.blueprint_id))
|
||||
recent_job_rows = db.execute(
|
||||
select(Job, User, Server)
|
||||
.outerjoin(User, User.id == Job.user_id)
|
||||
.outerjoin(Server, Server.id == Job.server_id)
|
||||
.where(Job.server_id == server.id)
|
||||
.order_by(Job.created_at.desc())
|
||||
.limit(5)
|
||||
).all()
|
||||
ctx = _build_server_actions_context(db, server)
|
||||
|
||||
connect_host = request.host.split(":")[0]
|
||||
|
||||
return render_template(
|
||||
"server_detail.html",
|
||||
server=server,
|
||||
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")
|
||||
@require_login
|
||||
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)
|
||||
|
||||
|
||||
_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>")
|
||||
@require_login
|
||||
def overlay_detail(overlay_id: int):
|
||||
|
|
@ -217,12 +358,7 @@ def overlay_detail(overlay_id: int):
|
|||
.where(OverlayWorkshopItem.overlay_id == overlay.id)
|
||||
.order_by(WorkshopItem.created_at)
|
||||
).all()
|
||||
latest_build_job = db.scalar(
|
||||
select(Job)
|
||||
.where(Job.operation == "build_overlay", Job.overlay_id == overlay.id)
|
||||
.order_by(Job.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
build_ctx = _build_overlay_build_status_context(db, 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,
|
||||
using_blueprints=using_blueprints,
|
||||
workshop_items=workshop_items,
|
||||
latest_build_job=latest_build_job,
|
||||
file_tree_root_entries=file_tree_root_entries,
|
||||
file_tree_truncated=file_tree_truncated_count > 0
|
||||
if file_tree_root_entries is not None
|
||||
else False,
|
||||
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]:
|
||||
"""Return (entries, truncated_count) for the overlay's runtime directory,
|
||||
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)
|
||||
.where(BlueprintOverlay.blueprint_id == blueprint.id)
|
||||
).all()
|
||||
expose_rows = db.execute(
|
||||
select(BlueprintOverlay.overlay_id, BlueprintOverlay.expose_server_cfg)
|
||||
.where(BlueprintOverlay.blueprint_id == blueprint.id)
|
||||
).all()
|
||||
all_overlays = db.scalars(
|
||||
select(Overlay)
|
||||
.where(Overlay.user_id.is_(None) | (Overlay.user_id == user.id))
|
||||
|
|
@ -297,6 +452,7 @@ def blueprint_page(blueprint_id: int):
|
|||
).all()
|
||||
|
||||
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}
|
||||
available_overlays = [overlay for overlay in all_overlays if overlay.id not in selected_ids]
|
||||
return render_template(
|
||||
|
|
@ -307,6 +463,7 @@ def blueprint_page(blueprint_id: int):
|
|||
all_overlays=all_overlays,
|
||||
selected_overlay_ids=selected_ids,
|
||||
overlay_positions=overlay_positions,
|
||||
overlay_expose_state=overlay_expose_state,
|
||||
arguments=json.loads(blueprint.arguments),
|
||||
config_lines=json.loads(blueprint.config),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
function streamTextToElement(element) {
|
||||
if (element.dataset.sseBound === "true") {
|
||||
return;
|
||||
}
|
||||
const url = element.dataset.sseUrl;
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = new EventSource(url);
|
||||
element._sseSource = source;
|
||||
element.dataset.sseBound = "true";
|
||||
|
||||
const appendLine = (line) => {
|
||||
element.textContent += `${line}\n`;
|
||||
|
|
@ -24,6 +28,37 @@ function streamTextToElement(element) {
|
|||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.querySelectorAll("[data-sse-url]").forEach(streamTextToElement);
|
||||
function bindSseIn(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(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),
|
||||
);
|
||||
|
|
|
|||
2
l4d2web/static/vendor/htmx.min.js
vendored
2
l4d2web/static/vendor/htmx.min.js
vendored
File diff suppressed because one or more lines are too long
18
l4d2web/templates/_overlay_build_status.html
Normal file
18
l4d2web/templates/_overlay_build_status.html
Normal 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>
|
||||
36
l4d2web/templates/_server_actions.html
Normal file
36
l4d2web/templates/_server_actions.html
Normal 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>
|
||||
|
|
@ -3,61 +3,19 @@
|
|||
{% block title %}Overlay {{ overlay.name }} | left4me{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set can_edit = g.user.admin or (overlay.type in ['workshop', 'script'] and overlay.user_id == g.user.id) %}
|
||||
<section class="panel">
|
||||
<div class="page-heading">
|
||||
<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>
|
||||
|
||||
{% if can_edit %}
|
||||
<form method="post" action="/overlays/{{ overlay.id }}" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<label>Name <input name="name" value="{{ overlay.name }}" required></label>
|
||||
<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>
|
||||
<dl class="server-info">
|
||||
<div><dt>Type</dt><dd>{{ overlay.type }}</dd></div>
|
||||
<div><dt>Scope</dt><dd>{% if overlay.user_id %}private{% else %}system{% endif %}</dd></div>
|
||||
</dl>
|
||||
|
||||
{% 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>
|
||||
|
||||
<h2 class="section-title">Script</h2>
|
||||
{% if can_edit %}
|
||||
<form method="post" action="/overlays/{{ overlay.id }}/script" class="stack">
|
||||
<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>
|
||||
</label>
|
||||
<p class="muted">Runs sandboxed against the overlay directory mounted at <code>/overlay</code>.</p>
|
||||
<div>
|
||||
<button type="submit">Save and build</button>
|
||||
{% if not latest_build_is_running %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% else %}
|
||||
<pre class="script-preview">{{ overlay.script or "" }}</pre>
|
||||
{% endif %}
|
||||
|
||||
<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 %}
|
||||
<a href="/overlays/{{ overlay.id }}/jobs">all builds →</a>
|
||||
</p>
|
||||
</section>
|
||||
{% include "_overlay_build_status.html" %}
|
||||
{% endif %}
|
||||
|
||||
{% if overlay.type == 'workshop' %}
|
||||
<section class="panel">
|
||||
<div class="page-heading">
|
||||
<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 %}
|
||||
<h2 class="section-title">Workshop items</h2>
|
||||
{% if can_edit and not latest_build_is_running %}
|
||||
<form method="post" action="/overlays/{{ overlay.id }}/items" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<fieldset class="workshop-input-mode">
|
||||
|
|
@ -105,32 +47,18 @@
|
|||
<label><input type="radio" name="input_mode" value="collection"> Collection (one ID or URL)</label>
|
||||
</fieldset>
|
||||
<label>Workshop input <textarea name="input" rows="3" placeholder="123456789 https://steamcommunity.com/sharedfiles/filedetails/?id=987654321"></textarea></label>
|
||||
<div>
|
||||
<button type="submit">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div id="overlay-item-table">
|
||||
{% include "_overlay_item_table.html" with context %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<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 %}
|
||||
<a href="/overlays/{{ overlay.id }}/jobs">all builds →</a>
|
||||
</p>
|
||||
</section>
|
||||
{% include "_overlay_build_status.html" %}
|
||||
{% endif %}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Files</h2>
|
||||
<h2 class="section-title">Files</h2>
|
||||
{% if not file_tree_root_entries %}
|
||||
<p class="muted">No files yet — build this overlay to populate it.</p>
|
||||
{% else %}
|
||||
|
|
@ -139,10 +67,8 @@
|
|||
{% set truncated_count = file_tree_truncated_count %}
|
||||
{% include "_overlay_file_tree.html" %}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Used by</h2>
|
||||
<h2 class="section-title">Used by</h2>
|
||||
{% if using_blueprints %}
|
||||
<ul class="used-by-list">
|
||||
{% for blueprint in using_blueprints %}
|
||||
|
|
@ -155,6 +81,25 @@
|
|||
</section>
|
||||
|
||||
{% 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">×</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">
|
||||
<div class="modal-header">
|
||||
<h2 id="delete-overlay-title">Delete overlay "{{ overlay.name }}"?</h2>
|
||||
|
|
@ -171,24 +116,5 @@
|
|||
</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">×</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 %}
|
||||
|
|
|
|||
|
|
@ -6,48 +6,39 @@
|
|||
<section class="panel">
|
||||
<div class="page-heading">
|
||||
<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>
|
||||
|
||||
<table class="definition-table">
|
||||
<tbody>
|
||||
<tr><th>Name</th><td>{{ server.name }}</td></tr>
|
||||
<tr><th>Port</th><td>{{ server.port }}</td></tr>
|
||||
<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>
|
||||
<dl class="server-info">
|
||||
<div><dt>Port</dt><dd><a href="steam://run/550//+connect%20{{ connect_host }}:{{ server.port }}">{{ server.port }}</a></dd></div>
|
||||
<div><dt>Blueprint</dt><dd>{% if blueprint %}<a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a>{% endif %}</dd></div>
|
||||
</dl>
|
||||
|
||||
<section class="panel">
|
||||
<div class="page-heading">
|
||||
<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>
|
||||
<h2 class="section-title">Actions</h2>
|
||||
{% include "_server_actions.html" %}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Server Log</h2>
|
||||
<h2 class="section-title">Server Log</h2>
|
||||
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
|
||||
</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">×</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">
|
||||
<div class="modal-header">
|
||||
<h2 id="reset-server-title">Reset server "{{ server.name }}"?</h2>
|
||||
|
|
|
|||
|
|
@ -129,17 +129,50 @@ def test_server_detail_shows_operations_and_logs(auth_client_with_server) -> Non
|
|||
|
||||
assert response.status_code == 200
|
||||
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/stop"' in text
|
||||
assert 'action="/servers/1/initialize"' in text
|
||||
assert 'action="/servers/1/delete"' in text
|
||||
assert 'action="/servers/1/stop"' not in text
|
||||
assert 'action="/servers/1/initialize"' not in text # UI dropped this; start auto-inits.
|
||||
assert 'action="/servers/1/delete"' in text # inside the delete modal
|
||||
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
|
||||
# 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:
|
||||
job = Job(user_id=1, server_id=1, operation="start", state="queued")
|
||||
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)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Recent Jobs" in text
|
||||
assert 'href="/servers/1/jobs"' in text
|
||||
# Active job → no Start/Stop/Reset, only the last-job sentence.
|
||||
assert 'action="/servers/1/start"' not 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:
|
||||
|
|
@ -455,7 +491,7 @@ def test_blueprint_detail_has_ordered_overlay_form(auth_client_with_server) -> N
|
|||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Overlay order matters" in text
|
||||
assert ">Overlays<" in text
|
||||
assert 'name="arguments"' in text
|
||||
assert 'name="config"' 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 '<textarea name="script"' in text
|
||||
assert "Rebuild" in text
|
||||
assert "Wipe" in text
|
||||
assert "Last build" in text
|
||||
# Two compound submits replaced standalone Rebuild/Wipe.
|
||||
assert "Save and 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:
|
||||
|
|
@ -566,6 +659,10 @@ def test_overlay_detail_links_to_overlay_jobs_page(auth_client_with_server) -> N
|
|||
with session_scope() as s:
|
||||
user_id = s.query(User).filter_by(username="alice").one().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}")
|
||||
text = response.get_data(as_text=True)
|
||||
|
|
|
|||
Loading…
Reference in a new issue