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:
parent
985df970f8
commit
3c4bd6880a
9 changed files with 246 additions and 19 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
29
l4d2web/services/timeago.py
Normal file
29
l4d2web/services/timeago.py
Normal 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()
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue