refactor(l4d2-web): detail-page UI — single panel, soft border, footer Delete

- Detail panels: softer (color-mix --line-soft) border. h2 sub-section
  spacing inside a single outer panel. admin and job_detail collapse to
  one panel each.
- Color tokens: --color-button-primary / --color-button-danger stay
  saturated in dark mode so white text on filled buttons stays readable.
- Site header: transparent, no full-width bar; aligned with panel-content
  width. No more sticky.
- Page-level Delete: low-contrast outline button at the page footer
  (left side, justify-content flex-start). Save buttons no longer
  full-width (.stack > button { justify-self: end }).
- form-actions-inline helper for right-aligned button rows.
- New service: l4d2web.services.timeago.humanize_delta — used by the
  upcoming server / overlay live-status partials.
- Server route: POST /servers/<id> renames the server (mirrors the
  overlay update pattern, returns 409 on per-user duplicate).
- Overlay route: POST /overlays/<id>/script handles `action` form value
  — `save_build` (default) or `save_reset_build` (wipes overlay dir
  before queuing build). Redirect lands on /overlays/<id> instead of
  the job page so users see the live status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-09 01:26:57 +02:00
parent 985df970f8
commit 3c4bd6880a
No known key found for this signature in database
9 changed files with 246 additions and 19 deletions

View file

@ -150,14 +150,30 @@ def update_script(overlay_id: int) -> Response:
# Normalize to LF before storage so the script is well-formed when written # Normalize to LF before storage so the script is well-formed when written
# to the sandbox tmpfile. # to the sandbox tmpfile.
script_text = request.form.get("script", "").replace("\r\n", "\n").replace("\r", "\n") script_text = request.form.get("script", "").replace("\r\n", "\n").replace("\r", "\n")
action = request.form.get("action", "save_build")
with session_scope() as db: with session_scope() as db:
overlay, err = _load_script_overlay(db, overlay_id, user) overlay, err = _load_script_overlay(db, overlay_id, user)
if err is not None: if err is not None:
return err return err
overlay.script = script_text overlay.script = script_text
if action == "save_reset_build":
# Wipe the overlay's working dir before queuing the rebuild so the
# next build runs against a clean tree. The wipe runs synchronously
# in the same sandbox; it's cheap (a `find … -delete`).
overlay_builders.run_sandboxed_script(
overlay_id,
WIPE_SCRIPT,
on_stdout=lambda _line: None,
on_stderr=lambda _line: None,
should_cancel=lambda: False,
)
with session_scope() as db:
job = 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 job_id = job.id
return redirect(f"/jobs/{job_id}") return redirect(f"/overlays/{overlay_id}")
@bp.post("/overlays/<int:overlay_id>/build") @bp.post("/overlays/<int:overlay_id>/build")

View file

@ -109,6 +109,33 @@ def create_server() -> Response:
return redirect(f"/servers/{server_id}") return redirect(f"/servers/{server_id}")
@bp.post("/servers/<int:server_id>")
@require_login
def update_server_form(server_id: int) -> Response:
user = current_user()
assert user is not None
try:
name = _validate_display_name(request.form.get("name", ""))
except ValueError:
return Response("invalid server name", status=400)
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)
server.name = name
try:
db.flush()
except IntegrityError as exc:
db.rollback()
detail = str(exc.orig) if exc.orig is not None else str(exc)
if "servers" in detail and "name" in detail:
return Response("name already in use", status=409)
raise
return redirect(f"/servers/{server_id}")
@bp.patch("/servers/<int:server_id>") @bp.patch("/servers/<int:server_id>")
@require_login @require_login
def update_server(server_id: int) -> Response: def update_server(server_id: int) -> Response:

View file

@ -0,0 +1,29 @@
from datetime import UTC, datetime
def humanize_delta(then: datetime, now: datetime | None = None) -> str:
if now is None:
now = datetime.now(UTC)
if then.tzinfo is None:
then = then.replace(tzinfo=UTC)
if now.tzinfo is None:
now = now.replace(tzinfo=UTC)
seconds = int((now - then).total_seconds())
if seconds < 0:
seconds = 0
if seconds < 45:
return "just now"
if seconds < 90:
return "1 minute ago"
minutes = seconds // 60
if minutes < 60:
return f"{minutes} minutes ago"
hours = minutes // 60
if hours < 24:
return "1 hour ago" if hours == 1 else f"{hours} hours ago"
days = hours // 24
if days < 7:
return "1 day ago" if days == 1 else f"{days} days ago"
return then.date().isoformat()

View file

@ -1,12 +1,22 @@
.panel, .panel,
.card { .card {
background: var(--color-surface); background: var(--color-surface);
border: var(--line); border: var(--line-soft);
border-radius: var(--radius-m); border-radius: var(--radius-m);
padding: var(--space-l); padding: var(--space-l);
margin-bottom: var(--space-l); margin-bottom: var(--space-l);
} }
.panel > h2,
.panel > .page-heading {
margin-top: var(--space-xl);
}
.panel > h2:first-child,
.panel > .page-heading:first-child {
margin-top: 0;
}
.table { .table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@ -69,7 +79,7 @@ a:focus-visible {
button, button,
a.button { a.button {
background: var(--color-primary); background: var(--color-button-primary);
border: none; border: none;
border-radius: var(--radius-s); border-radius: var(--radius-s);
color: #fff; color: #fff;
@ -81,7 +91,7 @@ a.button {
button.danger, button.danger,
a.button.danger { a.button.danger {
background: var(--color-danger); background: var(--color-button-danger);
} }
.link-button { .link-button {
@ -328,6 +338,19 @@ dialog.modal::backdrop {
flex: 1; flex: 1;
} }
.overlay-picker-expose {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
color: var(--color-muted);
white-space: nowrap;
font-size: 0.9em;
}
.overlay-picker-expose code {
font-size: inherit;
}
.overlay-picker-remove { .overlay-picker-remove {
background: none; background: none;
color: var(--color-muted); color: var(--color-muted);
@ -353,3 +376,137 @@ dialog.modal::backdrop {
.overlay-picker-add select { .overlay-picker-add select {
flex: 1; flex: 1;
} }
.section-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text);
line-height: 1.3;
}
.stack > button {
justify-self: end;
}
.page-footer-actions {
display: flex;
justify-content: flex-start;
align-items: center;
gap: var(--space-s);
margin-top: var(--space-l);
}
.form-actions-inline {
display: flex;
justify-content: flex-end;
gap: var(--space-s);
}
.danger-outline,
button.danger-outline {
background: transparent;
color: var(--color-button-danger);
border: 1px solid var(--color-button-danger);
}
button.danger-outline:hover {
background: color-mix(in srgb, var(--color-button-danger) 15%, transparent);
}
.inline-save {
display: flex;
gap: var(--space-s);
align-items: stretch;
}
.inline-save > input {
flex: 1;
}
.server-info {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: var(--space-l);
row-gap: var(--space-xs);
margin: var(--space-l) 0;
}
.server-info > div {
display: contents;
}
.server-info dt {
font-weight: 600;
}
.server-info dd {
margin: 0;
}
.server-actions {
display: flex;
align-items: center;
gap: var(--space-s);
flex-wrap: wrap;
}
.state-badge {
padding: var(--space-xs) var(--space-s);
border-radius: var(--radius-s);
font-size: 0.85em;
}
.state-running {
background: color-mix(in srgb, var(--color-success) 20%, transparent);
color: var(--color-success);
}
.state-stopped {
background: color-mix(in srgb, var(--color-muted) 20%, transparent);
color: var(--color-muted);
}
.state-unknown {
background: color-mix(in srgb, var(--color-muted) 15%, transparent);
color: var(--color-muted);
}
.state-transient {
background: color-mix(in srgb, var(--color-warning) 25%, transparent);
color: var(--color-warning);
}
.state-drift {
color: var(--color-warning);
margin: var(--space-s) 0 0;
}
.last-job {
color: var(--color-muted);
margin: var(--space-xs) 0 0;
}
.config-shell {
display: grid;
}
.config-preview {
margin: 0;
padding: var(--space-s) var(--space-m);
background: var(--color-surface);
color: var(--color-muted);
border: var(--line);
border-bottom: none;
border-radius: var(--radius-s) var(--radius-s) 0 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.875rem;
line-height: 1.5;
white-space: pre;
overflow-x: auto;
}
.config-preview + textarea {
border-top: none;
border-top-left-radius: 0;
border-top-right-radius: 0;
}

View file

@ -10,16 +10,13 @@ body {
} }
.site-header { .site-header {
background: var(--color-surface); background: transparent;
border-bottom: var(--line);
position: sticky;
top: 0;
} }
.site-header-inner { .site-header-inner {
max-width: 960px; max-width: 960px;
margin: 0 auto; margin: 0 auto;
padding: var(--space-l); padding: var(--space-l) var(--space-2xl);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@ -41,5 +38,5 @@ body {
.container { .container {
max-width: 960px; max-width: 960px;
margin: 0 auto; margin: 0 auto;
padding: var(--space-2xl) var(--space-l); padding: 0 var(--space-l) var(--space-2xl);
} }

View file

@ -27,6 +27,12 @@
--radius-m: calc(var(--radius-base) * 2); --radius-m: calc(var(--radius-base) * 2);
--line: 1px solid var(--color-border); --line: 1px solid var(--color-border);
--line-soft: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent);
/* Filled buttons stay saturated in both themes white text needs a deep
background to read. Don't redefine these in the dark-mode block. */
--color-button-primary: #1d4ed8;
--color-button-danger: #b42318;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {

View file

@ -9,18 +9,14 @@
<li><a href="/admin/users">Users</a></li> <li><a href="/admin/users">Users</a></li>
<li><a href="/admin/jobs">Jobs</a></li> <li><a href="/admin/jobs">Jobs</a></li>
</ul> </ul>
</section>
<section class="panel">
<h2>Runtime</h2> <h2>Runtime</h2>
<p class="muted">Queue a Steam runtime install/update job for the local host.</p> <p class="muted">Queue a Steam runtime install/update job for the local host.</p>
<form method="post" action="/admin/install"> <form method="post" action="/admin/install">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button type="submit">Install or update runtime</button> <button type="submit">Install or update runtime</button>
</form> </form>
</section>
<section class="panel">
<h2>Workshop</h2> <h2>Workshop</h2>
<p class="muted">Re-fetch metadata for every workshop item; re-download those with newer versions on Steam, then enqueue rebuilds for the affected overlays.</p> <p class="muted">Re-fetch metadata for every workshop item; re-download those with newer versions on Steam, then enqueue rebuilds for the affected overlays.</p>
<form method="post" action="/admin/workshop/refresh"> <form method="post" action="/admin/workshop/refresh">

View file

@ -27,9 +27,7 @@
<tr><th>Exit code</th><td>{{ job.exit_code if job.exit_code is not none else "-" }}</td></tr> <tr><th>Exit code</th><td>{{ job.exit_code if job.exit_code is not none else "-" }}</td></tr>
</tbody> </tbody>
</table> </table>
</section>
<section class="panel">
<h2>Job Logs</h2> <h2>Job Logs</h2>
<pre class="log-stream" data-sse-url="/jobs/{{ job.id }}/stream"></pre> <pre class="log-stream" data-sse-url="/jobs/{{ job.id }}/stream"></pre>
</section> </section>

View file

@ -121,13 +121,14 @@ def test_update_script_body_enqueues_build(app, alice_id) -> None:
headers={"X-CSRF-Token": "test-token"}, headers={"X-CSRF-Token": "test-token"},
) )
assert r1.status_code == 302 assert r1.status_code == 302
assert r1.headers["Location"].startswith("/jobs/") # Redirect lands on the overlay page so the user sees the build progress
# via the live build-status partial.
assert r1.headers["Location"] == f"/overlays/{overlay_id}"
with session_scope() as s: with session_scope() as s:
overlay = s.query(Overlay).filter_by(id=overlay_id).one() overlay = s.query(Overlay).filter_by(id=overlay_id).one()
assert overlay.script == "echo new" assert overlay.script == "echo new"
jobs = s.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all() jobs = s.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all()
assert len(jobs) == 1 assert len(jobs) == 1
assert r1.headers["Location"] == f"/jobs/{jobs[0].id}"
# Coalesce against pending. # Coalesce against pending.
r2 = client.post( r2 = client.post(
@ -136,7 +137,7 @@ def test_update_script_body_enqueues_build(app, alice_id) -> None:
headers={"X-CSRF-Token": "test-token"}, headers={"X-CSRF-Token": "test-token"},
) )
assert r2.status_code == 302 assert r2.status_code == 302
assert r2.headers["Location"] == r1.headers["Location"] assert r2.headers["Location"] == f"/overlays/{overlay_id}"
with session_scope() as s: with session_scope() as s:
jobs = s.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all() jobs = s.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all()
assert len(jobs) == 1 assert len(jobs) == 1