diff --git a/l4d2web/routes/overlay_routes.py b/l4d2web/routes/overlay_routes.py index a2731c9..f412c3c 100644 --- a/l4d2web/routes/overlay_routes.py +++ b/l4d2web/routes/overlay_routes.py @@ -150,14 +150,30 @@ def update_script(overlay_id: int) -> Response: # Normalize to LF before storage so the script is well-formed when written # to the sandbox tmpfile. 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: overlay, err = _load_script_overlay(db, overlay_id, user) if err is not None: return err 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_id = job.id - return redirect(f"/jobs/{job_id}") + return redirect(f"/overlays/{overlay_id}") @bp.post("/overlays//build") diff --git a/l4d2web/routes/server_routes.py b/l4d2web/routes/server_routes.py index bb77c0a..d778032 100644 --- a/l4d2web/routes/server_routes.py +++ b/l4d2web/routes/server_routes.py @@ -109,6 +109,33 @@ def create_server() -> Response: return redirect(f"/servers/{server_id}") +@bp.post("/servers/") +@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/") @require_login def update_server(server_id: int) -> Response: diff --git a/l4d2web/services/timeago.py b/l4d2web/services/timeago.py new file mode 100644 index 0000000..5906b2e --- /dev/null +++ b/l4d2web/services/timeago.py @@ -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() diff --git a/l4d2web/static/css/components.css b/l4d2web/static/css/components.css index 1e81a9e..cbac0e9 100644 --- a/l4d2web/static/css/components.css +++ b/l4d2web/static/css/components.css @@ -1,12 +1,22 @@ .panel, .card { background: var(--color-surface); - border: var(--line); + border: var(--line-soft); border-radius: var(--radius-m); padding: 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 { width: 100%; border-collapse: collapse; @@ -69,7 +79,7 @@ a:focus-visible { button, a.button { - background: var(--color-primary); + background: var(--color-button-primary); border: none; border-radius: var(--radius-s); color: #fff; @@ -81,7 +91,7 @@ a.button { button.danger, a.button.danger { - background: var(--color-danger); + background: var(--color-button-danger); } .link-button { @@ -328,6 +338,19 @@ dialog.modal::backdrop { 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 { background: none; color: var(--color-muted); @@ -353,3 +376,137 @@ dialog.modal::backdrop { .overlay-picker-add select { 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; +} diff --git a/l4d2web/static/css/layout.css b/l4d2web/static/css/layout.css index db176c2..4b90826 100644 --- a/l4d2web/static/css/layout.css +++ b/l4d2web/static/css/layout.css @@ -10,16 +10,13 @@ body { } .site-header { - background: var(--color-surface); - border-bottom: var(--line); - position: sticky; - top: 0; + background: transparent; } .site-header-inner { max-width: 960px; margin: 0 auto; - padding: var(--space-l); + padding: var(--space-l) var(--space-2xl); display: flex; justify-content: space-between; align-items: center; @@ -41,5 +38,5 @@ body { .container { max-width: 960px; margin: 0 auto; - padding: var(--space-2xl) var(--space-l); + padding: 0 var(--space-l) var(--space-2xl); } diff --git a/l4d2web/static/css/tokens.css b/l4d2web/static/css/tokens.css index 1a20e30..023670b 100644 --- a/l4d2web/static/css/tokens.css +++ b/l4d2web/static/css/tokens.css @@ -27,6 +27,12 @@ --radius-m: calc(var(--radius-base) * 2); --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) { diff --git a/l4d2web/templates/admin.html b/l4d2web/templates/admin.html index f0eea62..283e695 100644 --- a/l4d2web/templates/admin.html +++ b/l4d2web/templates/admin.html @@ -9,18 +9,14 @@
  • Users
  • Jobs
  • - -

    Runtime

    Queue a Steam runtime install/update job for the local host.

    -
    -

    Workshop

    Re-fetch metadata for every workshop item; re-download those with newer versions on Steam, then enqueue rebuilds for the affected overlays.

    diff --git a/l4d2web/templates/job_detail.html b/l4d2web/templates/job_detail.html index 2ab3f56..bbf8137 100644 --- a/l4d2web/templates/job_detail.html +++ b/l4d2web/templates/job_detail.html @@ -27,9 +27,7 @@ Exit code{{ job.exit_code if job.exit_code is not none else "-" }} -
    -

    Job Logs

    
     
    diff --git a/l4d2web/tests/test_script_overlay_routes.py b/l4d2web/tests/test_script_overlay_routes.py index edb2a72..feb1994 100644 --- a/l4d2web/tests/test_script_overlay_routes.py +++ b/l4d2web/tests/test_script_overlay_routes.py @@ -121,13 +121,14 @@ def test_update_script_body_enqueues_build(app, alice_id) -> None: headers={"X-CSRF-Token": "test-token"}, ) 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: overlay = s.query(Overlay).filter_by(id=overlay_id).one() assert overlay.script == "echo new" jobs = s.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all() assert len(jobs) == 1 - assert r1.headers["Location"] == f"/jobs/{jobs[0].id}" # Coalesce against pending. r2 = client.post( @@ -136,7 +137,7 @@ def test_update_script_body_enqueues_build(app, alice_id) -> None: headers={"X-CSRF-Token": "test-token"}, ) 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: jobs = s.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all() assert len(jobs) == 1