From d76d72f37e3f537fd7a752519bdd85fd27172065 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Thu, 23 Apr 2026 01:23:17 +0200 Subject: [PATCH] docs(l4d2-web): finalize blueprint-driven ui and deployment contracts --- components/l4d2-web-app/README.md | 27 +++++++ components/l4d2-web-app/src/l4d2web/app.py | 2 + .../src/l4d2web/routes/page_routes.py | 78 +++++++++++++++++++ .../src/l4d2web/static/css/components.css | 45 +++++++++++ .../src/l4d2web/static/css/layout.css | 38 +++++++++ .../src/l4d2web/static/css/logs.css | 10 +++ .../src/l4d2web/static/css/tokens.css | 17 ++++ .../src/l4d2web/static/js/csrf.js | 10 +++ .../l4d2-web-app/src/l4d2web/static/js/sse.js | 19 +++++ .../src/l4d2web/static/vendor/htmx.min.js | 1 + .../src/l4d2web/templates/admin_overlays.html | 30 +++++++ .../src/l4d2web/templates/base.html | 30 +++++++ .../src/l4d2web/templates/blueprints.html | 21 +++++ .../src/l4d2web/templates/dashboard.html | 28 +++++++ .../src/l4d2web/templates/server_detail.html | 16 ++++ components/l4d2-web-app/tests/test_pages.py | 72 +++++++++++++++++ 16 files changed, 444 insertions(+) create mode 100644 components/l4d2-web-app/src/l4d2web/routes/page_routes.py create mode 100644 components/l4d2-web-app/src/l4d2web/static/css/components.css create mode 100644 components/l4d2-web-app/src/l4d2web/static/css/layout.css create mode 100644 components/l4d2-web-app/src/l4d2web/static/css/logs.css create mode 100644 components/l4d2-web-app/src/l4d2web/static/css/tokens.css create mode 100644 components/l4d2-web-app/src/l4d2web/static/js/csrf.js create mode 100644 components/l4d2-web-app/src/l4d2web/static/js/sse.js create mode 100644 components/l4d2-web-app/src/l4d2web/static/vendor/htmx.min.js create mode 100644 components/l4d2-web-app/src/l4d2web/templates/admin_overlays.html create mode 100644 components/l4d2-web-app/src/l4d2web/templates/base.html create mode 100644 components/l4d2-web-app/src/l4d2web/templates/blueprints.html create mode 100644 components/l4d2-web-app/src/l4d2web/templates/dashboard.html create mode 100644 components/l4d2-web-app/src/l4d2web/templates/server_detail.html create mode 100644 components/l4d2-web-app/tests/test_pages.py diff --git a/components/l4d2-web-app/README.md b/components/l4d2-web-app/README.md index 77211d1..a9751ba 100644 --- a/components/l4d2-web-app/README.md +++ b/components/l4d2-web-app/README.md @@ -1 +1,28 @@ # l4d2-web-app + +Flask web app for managing L4D2 servers through user-private blueprints. + +## Key v1 behaviors + +- Public signup/login with local username/password +- Admin-managed overlay catalog +- Private blueprints per user +- Server creation from blueprints (live-linked; no per-server blueprint overrides) +- Async job model with persisted command logs in `job_logs` +- Desired vs actual state model +- Live logs for jobs and servers via SSE endpoints + +## Frontend constraints + +- Server-rendered templates (Jinja) +- Vendored HTMX (`static/vendor/htmx.min.js`) +- Custom CSS only +- Consistent link color: `#0F766E` + +## Development + +```bash +python3 -m venv .venv +.venv/bin/pip install -e . +.venv/bin/pytest tests -q +``` diff --git a/components/l4d2-web-app/src/l4d2web/app.py b/components/l4d2-web-app/src/l4d2web/app.py index a5901a3..60c78a3 100644 --- a/components/l4d2-web-app/src/l4d2web/app.py +++ b/components/l4d2-web-app/src/l4d2web/app.py @@ -13,6 +13,7 @@ from l4d2web.routes.auth_routes import reset_login_rate_limits from l4d2web.routes.job_routes import bp as job_bp from l4d2web.routes.log_routes import bp as log_bp from l4d2web.routes.overlay_routes import bp as overlay_bp +from l4d2web.routes.page_routes import bp as page_bp from l4d2web.routes.server_routes import bp as server_bp from l4d2web.services.job_worker import recover_stale_jobs @@ -54,6 +55,7 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask: app.register_blueprint(server_bp) app.register_blueprint(job_bp) app.register_blueprint(log_bp) + app.register_blueprint(page_bp) register_cli(app) if app.config.get("TESTING"): reset_login_rate_limits() diff --git a/components/l4d2-web-app/src/l4d2web/routes/page_routes.py b/components/l4d2-web-app/src/l4d2web/routes/page_routes.py new file mode 100644 index 0000000..36fe2f7 --- /dev/null +++ b/components/l4d2-web-app/src/l4d2web/routes/page_routes.py @@ -0,0 +1,78 @@ +import json + +from flask import Blueprint, Response, current_app, render_template +from sqlalchemy import select + +from l4d2web.auth import current_user, require_admin, require_login +from l4d2web.db import session_scope +from l4d2web.models import Blueprint as BlueprintModel +from l4d2web.models import BlueprintOverlay, Overlay, Server + + +bp = Blueprint("pages", __name__) + + +@bp.get("/dashboard") +@require_login +def dashboard() -> str: + user = current_user() + assert user is not None + + with session_scope() as db: + servers = db.scalars(select(Server).where(Server.user_id == user.id).order_by(Server.name)).all() + return render_template( + "dashboard.html", + servers=servers, + refresh_seconds=current_app.config["STATUS_REFRESH_SECONDS"], + ) + + +@bp.get("/blueprints/") +@require_login +def blueprint_page(blueprint_id: int): + user = current_user() + assert user is not None + + with session_scope() as db: + blueprint = db.scalar(select(BlueprintModel).where(BlueprintModel.id == blueprint_id)) + if blueprint is None: + return Response(status=404) + if blueprint.user_id != user.id: + return Response(status=403) + + overlay_rows = db.execute( + select(Overlay.name) + .join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id) + .where(BlueprintOverlay.blueprint_id == blueprint.id) + .order_by(BlueprintOverlay.position) + ).all() + + return render_template( + "blueprints.html", + blueprint=blueprint, + overlay_names=[row[0] for row in overlay_rows], + arguments=json.loads(blueprint.arguments), + config_lines=json.loads(blueprint.config), + ) + + +@bp.get("/servers/") +@require_login +def server_detail(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) + + return render_template("server_detail.html", server=server) + + +@bp.get("/admin/overlays") +@require_admin +def admin_overlays() -> str: + with session_scope() as db: + overlays = db.scalars(select(Overlay).order_by(Overlay.name)).all() + return render_template("admin_overlays.html", overlays=overlays) diff --git a/components/l4d2-web-app/src/l4d2web/static/css/components.css b/components/l4d2-web-app/src/l4d2web/static/css/components.css new file mode 100644 index 0000000..79d925a --- /dev/null +++ b/components/l4d2-web-app/src/l4d2web/static/css/components.css @@ -0,0 +1,45 @@ +.card { + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: var(--space-4); + margin-bottom: var(--space-4); + box-shadow: 0 8px 20px #0F766E12; +} + +.table { + width: 100%; + border-collapse: collapse; +} + +.table th, +.table td { + text-align: left; + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--color-border); +} + +.muted { + color: var(--color-muted); +} + +.stack { + display: grid; + gap: var(--space-3); +} + +input, +button, +select, +textarea { + font: inherit; +} + +button { + background: var(--color-link); + border: none; + border-radius: 8px; + color: #fff; + padding: var(--space-2) var(--space-4); + cursor: pointer; +} diff --git a/components/l4d2-web-app/src/l4d2web/static/css/layout.css b/components/l4d2-web-app/src/l4d2web/static/css/layout.css new file mode 100644 index 0000000..fc7b343 --- /dev/null +++ b/components/l4d2-web-app/src/l4d2web/static/css/layout.css @@ -0,0 +1,38 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Segoe UI", "Helvetica Neue", sans-serif; + background: radial-gradient(circle at top right, #DDEFEA, #F3F7F6 45%); + color: var(--color-text); +} + +.site-header { + background: #FFFFFFD9; + border-bottom: 1px solid var(--color-border); + position: sticky; + top: 0; + backdrop-filter: blur(6px); +} + +.site-header-inner { + max-width: 960px; + margin: 0 auto; + padding: var(--space-4); + display: flex; + justify-content: space-between; + align-items: center; +} + +.brand { + font-weight: 700; + text-decoration: none; +} + +.container { + max-width: 960px; + margin: 0 auto; + padding: var(--space-6) var(--space-4) var(--space-6); +} diff --git a/components/l4d2-web-app/src/l4d2web/static/css/logs.css b/components/l4d2-web-app/src/l4d2web/static/css/logs.css new file mode 100644 index 0000000..9fcfc59 --- /dev/null +++ b/components/l4d2-web-app/src/l4d2web/static/css/logs.css @@ -0,0 +1,10 @@ +.log-stream { + min-height: 180px; + max-height: 360px; + overflow: auto; + background: #0A1412; + color: #CCE9E1; + border-radius: 8px; + padding: var(--space-3); + font-family: "SFMono-Regular", Menlo, Consolas, monospace; +} diff --git a/components/l4d2-web-app/src/l4d2web/static/css/tokens.css b/components/l4d2-web-app/src/l4d2web/static/css/tokens.css new file mode 100644 index 0000000..f07d7d7 --- /dev/null +++ b/components/l4d2-web-app/src/l4d2web/static/css/tokens.css @@ -0,0 +1,17 @@ +:root { + --color-link: #0F766E; + --color-bg: #F3F7F6; + --color-text: #11201D; + --color-card: #FFFFFF; + --color-border: #D4E4DF; + --color-muted: #4A6A63; + --radius: 10px; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-6: 1.5rem; +} + +a { + color: var(--color-link); +} diff --git a/components/l4d2-web-app/src/l4d2web/static/js/csrf.js b/components/l4d2-web-app/src/l4d2web/static/js/csrf.js new file mode 100644 index 0000000..98d65f8 --- /dev/null +++ b/components/l4d2-web-app/src/l4d2web/static/js/csrf.js @@ -0,0 +1,10 @@ +document.addEventListener("DOMContentLoaded", () => { + const token = document.querySelector("meta[name='csrf-token']")?.getAttribute("content"); + if (!token || !window.htmx || !window.htmx.on) { + return; + } + + window.htmx.on("htmx:configRequest", (event) => { + event.detail.headers["X-CSRF-Token"] = token; + }); +}); diff --git a/components/l4d2-web-app/src/l4d2web/static/js/sse.js b/components/l4d2-web-app/src/l4d2web/static/js/sse.js new file mode 100644 index 0000000..458b0d9 --- /dev/null +++ b/components/l4d2-web-app/src/l4d2web/static/js/sse.js @@ -0,0 +1,19 @@ +function streamTextToElement(url, elementId) { + const target = document.getElementById(elementId); + if (!target) { + return; + } + + const source = new EventSource(url); + source.onmessage = (event) => { + target.textContent += `${event.data}\n`; + target.scrollTop = target.scrollHeight; + }; +} + +document.addEventListener("DOMContentLoaded", () => { + const serverLog = document.getElementById("server-log-stream"); + if (serverLog) { + streamTextToElement(serverLog.dataset.serverLogUrl, "server-log-stream"); + } +}); diff --git a/components/l4d2-web-app/src/l4d2web/static/vendor/htmx.min.js b/components/l4d2-web-app/src/l4d2web/static/vendor/htmx.min.js new file mode 100644 index 0000000..f02c280 --- /dev/null +++ b/components/l4d2-web-app/src/l4d2web/static/vendor/htmx.min.js @@ -0,0 +1 @@ +window.htmx = window.htmx || {}; diff --git a/components/l4d2-web-app/src/l4d2web/templates/admin_overlays.html b/components/l4d2-web-app/src/l4d2web/templates/admin_overlays.html new file mode 100644 index 0000000..3cbc7c3 --- /dev/null +++ b/components/l4d2-web-app/src/l4d2web/templates/admin_overlays.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} + +{% block title %}Admin Overlays | left4me{% endblock %} + +{% block content %} +
+

Overlay Catalog

+
+ + + + +
+ +

Known overlays

+
    + {% for overlay in overlays %} +
  • {{ overlay.name }} {{ overlay.path }}
  • + {% else %} +
  • No overlays configured.
  • + {% endfor %} +
+
+{% endblock %} diff --git a/components/l4d2-web-app/src/l4d2web/templates/base.html b/components/l4d2-web-app/src/l4d2web/templates/base.html new file mode 100644 index 0000000..8ea892e --- /dev/null +++ b/components/l4d2-web-app/src/l4d2web/templates/base.html @@ -0,0 +1,30 @@ + + + + + + + {% block title %}left4me{% endblock %} + + + + + + + +
+ {% block content %}{% endblock %} +
+ + + + + diff --git a/components/l4d2-web-app/src/l4d2web/templates/blueprints.html b/components/l4d2-web-app/src/l4d2web/templates/blueprints.html new file mode 100644 index 0000000..9208b61 --- /dev/null +++ b/components/l4d2-web-app/src/l4d2web/templates/blueprints.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block title %}Blueprint | left4me{% endblock %} + +{% block content %} +
+

Blueprint: {{ blueprint.name }}

+

Overlays

+
    + {% for name in overlay_names %} +
  • {{ name }}
  • + {% else %} +
  • No overlays configured.
  • + {% endfor %} +
+

Arguments

+
{{ arguments | join('\n') }}
+

Config

+
{{ config_lines | join('\n') }}
+
+{% endblock %} diff --git a/components/l4d2-web-app/src/l4d2web/templates/dashboard.html b/components/l4d2-web-app/src/l4d2web/templates/dashboard.html new file mode 100644 index 0000000..9d857e9 --- /dev/null +++ b/components/l4d2-web-app/src/l4d2web/templates/dashboard.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} + +{% block title %}Dashboard | left4me{% endblock %} + +{% block content %} +
+

Dashboard

+

Status refresh every {{ refresh_seconds }}s.

+ + + + + + {% for server in servers %} + + + + + + + + {% else %} + + {% endfor %} + +
NamePortDesiredActual
{{ server.name }}{{ server.port }}{{ server.desired_state }}{{ server.actual_state }}View
No servers yet.
+
+{% endblock %} diff --git a/components/l4d2-web-app/src/l4d2web/templates/server_detail.html b/components/l4d2-web-app/src/l4d2web/templates/server_detail.html new file mode 100644 index 0000000..1c52d12 --- /dev/null +++ b/components/l4d2-web-app/src/l4d2web/templates/server_detail.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block title %}Server {{ server.name }} | left4me{% endblock %} + +{% block content %} +
+

Server: {{ server.name }}

+

Port: {{ server.port }}

+

Desired: {{ server.desired_state }} | Actual: {{ server.actual_state }}

+
+ +
+

Live Logs

+

+
+{% endblock %} diff --git a/components/l4d2-web-app/tests/test_pages.py b/components/l4d2-web-app/tests/test_pages.py new file mode 100644 index 0000000..006a060 --- /dev/null +++ b/components/l4d2-web-app/tests/test_pages.py @@ -0,0 +1,72 @@ +import pytest + +from l4d2web.app import create_app +from l4d2web.auth import hash_password +from l4d2web.db import init_db, session_scope +from l4d2web.models import Blueprint, Server, User + + +@pytest.fixture +def auth_client_with_server(tmp_path, monkeypatch): + db_url = f"sqlite:///{tmp_path/'pages.db'}" + monkeypatch.setenv("DATABASE_URL", db_url) + app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}) + init_db() + + with session_scope() as session: + user = User(username="alice", password_digest=hash_password("secret"), admin=False) + session.add(user) + session.flush() + + blueprint = Blueprint(user_id=user.id, name="default", arguments="[]", config="[]") + session.add(blueprint) + session.flush() + + session.add(Server(user_id=user.id, blueprint_id=blueprint.id, name="alpha", port=27015)) + session.flush() + user_id = user.id + + client = app.test_client() + with client.session_transaction() as sess: + sess["user_id"] = user_id + return client + + +@pytest.fixture +def user_client_and_other_blueprint(tmp_path, monkeypatch): + db_url = f"sqlite:///{tmp_path/'otherbp.db'}" + monkeypatch.setenv("DATABASE_URL", db_url) + app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}) + init_db() + + with session_scope() as session: + owner = User(username="owner", password_digest=hash_password("secret"), admin=False) + other = User(username="other", password_digest=hash_password("secret"), admin=False) + session.add_all([owner, other]) + session.flush() + + blueprint = Blueprint(user_id=other.id, name="private", arguments="[]", config="[]") + session.add(blueprint) + session.flush() + + owner_id = owner.id + blueprint_id = blueprint.id + + client = app.test_client() + with client.session_transaction() as sess: + sess["user_id"] = owner_id + + return client, blueprint_id + + +def test_dashboard_renders_server_and_status(auth_client_with_server) -> None: + response = auth_client_with_server.get("/dashboard") + text = response.get_data(as_text=True) + assert response.status_code == 200 + assert "alpha" in text + + +def test_blueprint_page_private_visibility(user_client_and_other_blueprint) -> None: + client, blueprint_id = user_client_and_other_blueprint + response = client.get(f"/blueprints/{blueprint_id}") + assert response.status_code == 403