left4me/docs/superpowers/plans/2026-05-06-l4d2-web-ui.md
2026-05-06 11:34:23 +02:00

1376 lines
48 KiB
Markdown

# L4D2 Web UI Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement the approved functional admin-console UI for `l4d2web` without changing the Flask/Jinja/HTMX/SSE architecture.
**Architecture:** Treat the current `l4d2web` implementation as a rough draft, not a compatibility boundary. Keep the Flask/Jinja/HTMX/SSE architecture, but replace routes, templates, CSS, JavaScript, and tests where needed to match the approved UI contract. Use tokenized CSS for neutral light/dark themes, standard HTML forms for CRUD and lifecycle actions, HTMX only for small partial updates, and SSE for log streams.
**Tech Stack:** Python 3.12+, Flask, SQLAlchemy, pytest, Jinja templates, vendored HTMX, vanilla JavaScript, custom CSS, SSE.
---
## File Structure
- `l4d2web/routes/page_routes.py`: GET pages for dashboard, servers, blueprints, overlays, server detail, blueprint detail, admin, users, and jobs.
- `l4d2web/routes/overlay_routes.py`: admin-only overlay create, update, and delete form handlers under `/overlays`.
- `l4d2web/routes/blueprint_routes.py`: standard form create/update/delete handlers for blueprints and ordered overlays. Keep JSON handling only if it remains useful and does not constrain the UI.
- `l4d2web/routes/server_routes.py`: standard form lifecycle action handlers that enqueue `Job` rows. Keep JSON handling only if it remains useful and does not constrain the UI.
- `l4d2web/templates/base.html`: shared shell, nav, account area, logout POST form, CSS/JS includes.
- `l4d2web/templates/dashboard.html`: simple landing text.
- `l4d2web/templates/servers.html`: server list table.
- `l4d2web/templates/server_detail.html`: server operations, metadata, blueprint summary, job log, and server log.
- `l4d2web/templates/blueprints.html`: blueprint list table and create form.
- `l4d2web/templates/blueprint_detail.html`: blueprint edit form with ordered overlays and textareas.
- `l4d2web/templates/overlays.html`: single overlay catalog page with admin-only edit controls.
- `l4d2web/templates/admin.html`: admin landing page.
- `l4d2web/templates/admin_users.html`: admin user list.
- `l4d2web/templates/admin_jobs.html`: admin job list.
- `l4d2web/static/css/tokens.css`: all color, spacing, line, radius, and light/dark variables.
- `l4d2web/static/css/layout.css`: page shell and layout primitives using tokens.
- `l4d2web/static/css/components.css`: tables, forms, buttons, status badges, and panels using tokens.
- `l4d2web/static/css/logs.css`: tokenized log panels.
- `l4d2web/static/js/sse.js`: unobtrusive SSE binding for log elements with `data-sse-url`.
- `l4d2web/tests/test_pages.py`: page, shell, nav, server, blueprint, and admin page tests.
- `l4d2web/tests/test_overlays.py`: overlay page and admin form tests.
- `l4d2web/tests/test_blueprints.py`: form-based blueprint creation/editing tests plus any retained JSON behavior.
- `l4d2web/tests/test_servers.py`: server form and lifecycle enqueue tests plus any retained JSON behavior.
- `AGENTS.md`: remove the stale fixed link-color contract.
- `docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md`: remove the stale fixed link-color contract.
The current web code and tests may be replaced instead of carefully preserved. When this plan says to append a test or modify a draft file, an implementer may rewrite the file if that is clearer and still satisfies the task tests and final suite.
### Task 1: Implement shell, tokens, and dashboard landing text
**Files:**
- Modify: `l4d2web/templates/base.html`
- Modify: `l4d2web/templates/dashboard.html`
- Modify: `l4d2web/static/css/tokens.css`
- Modify: `l4d2web/static/css/layout.css`
- Modify: `l4d2web/static/css/components.css`
- Modify: `l4d2web/static/css/logs.css`
- Test: `l4d2web/tests/test_pages.py`
- [ ] **Step 1: Add failing shell and dashboard tests**
Append these tests to `l4d2web/tests/test_pages.py`:
```python
def test_dashboard_is_simple_landing_page(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 "Dashboard" in text
assert "Use the navigation to manage servers, blueprints, and overlays." in text
assert "alpha" not in text
def test_shell_nav_uses_main_sections(auth_client_with_server) -> None:
response = auth_client_with_server.get("/dashboard")
text = response.get_data(as_text=True)
assert 'href="/dashboard"' in text
assert 'href="/servers"' in text
assert 'href="/blueprints"' in text
assert 'href="/overlays"' in text
assert 'action="/logout"' in text
assert "csrf_token" in text
def test_css_tokens_define_neutral_light_and_dark_theme() -> None:
css = Path("l4d2web/static/css/tokens.css").read_text()
for token in [
"--color-bg",
"--color-surface",
"--color-text",
"--color-muted",
"--color-border",
"--color-link",
"--space-base",
"--space-xs",
"--space-s",
"--space-m",
"--space-l",
"--space-xl",
"--space-2xl",
"--radius-s",
"--radius-m",
"--line",
]:
assert token in css
assert "prefers-color-scheme: dark" in css
assert "radial-gradient" not in Path("l4d2web/static/css/layout.css").read_text()
```
Also add the import at the top of `l4d2web/tests/test_pages.py`:
```python
from pathlib import Path
```
- [ ] **Step 2: Run tests and verify failure**
Run: `pytest l4d2web/tests/test_pages.py::test_dashboard_is_simple_landing_page l4d2web/tests/test_pages.py::test_shell_nav_uses_main_sections l4d2web/tests/test_pages.py::test_css_tokens_define_neutral_light_and_dark_theme -q`
Expected: FAIL because the dashboard still renders servers and the CSS tokens are not the approved neutral theme.
- [ ] **Step 3: Update base shell and dashboard**
Change `l4d2web/templates/base.html` to use this shell shape:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ session.get('csrf_token', '') }}">
<title>{% block title %}left4me{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/tokens.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/layout.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/logs.css') }}">
</head>
<body>
<header class="site-header">
<div class="site-header-inner">
<nav class="primary-nav" aria-label="Main navigation">
<a class="brand" href="/dashboard">left4me</a>
<a href="/servers">servers</a>
<a href="/blueprints">blueprints</a>
<a href="/overlays">overlays</a>
</nav>
{% if g.user %}
<nav class="account-nav" aria-label="Account navigation">
{% if g.user.admin %}<a href="/admin">admin</a>{% endif %}
<span class="muted">{{ g.user.username }}</span>
<form method="post" action="/logout" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="link-button" type="submit">logout</button>
</form>
</nav>
{% endif %}
</div>
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
<script src="{{ url_for('static', filename='vendor/htmx.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/csrf.js') }}"></script>
<script src="{{ url_for('static', filename='js/sse.js') }}"></script>
</body>
</html>
```
Change `l4d2web/templates/dashboard.html` to:
```html
{% extends "base.html" %}
{% block title %}Dashboard | left4me{% endblock %}
{% block content %}
<section class="panel">
<h1>Dashboard</h1>
<p class="muted">Use the navigation to manage servers, blueprints, and overlays.</p>
</section>
{% endblock %}
```
- [ ] **Step 4: Update CSS tokens and layout**
Replace `l4d2web/static/css/tokens.css` with:
```css
:root {
--color-bg: #f4f4f5;
--color-surface: #ffffff;
--color-surface-muted: #f8fafc;
--color-text: #18181b;
--color-muted: #60646c;
--color-border: #d4d4d8;
--color-link: #1d4ed8;
--color-primary: #1d4ed8;
--color-danger: #b42318;
--color-warning: #a15c07;
--color-success: #067647;
--color-focus: #2563eb;
--color-log-bg: #111827;
--color-log-text: #e5e7eb;
--space-base: 0.25rem;
--space-xs: var(--space-base);
--space-s: calc(var(--space-base) * 2);
--space-m: calc(var(--space-base) * 3);
--space-l: calc(var(--space-base) * 4);
--space-xl: calc(var(--space-base) * 6);
--space-2xl: calc(var(--space-base) * 8);
--radius-base: 0.25rem;
--radius-s: var(--radius-base);
--radius-m: calc(var(--radius-base) * 2);
--line: 1px solid var(--color-border);
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #18181b;
--color-surface: #27272a;
--color-surface-muted: #1f1f23;
--color-text: #f4f4f5;
--color-muted: #a1a1aa;
--color-border: #3f3f46;
--color-link: #93c5fd;
--color-primary: #93c5fd;
--color-danger: #fca5a5;
--color-warning: #fcd34d;
--color-success: #86efac;
--color-focus: #bfdbfe;
}
}
a {
color: var(--color-link);
}
```
Update `layout.css`, `components.css`, and `logs.css` so all colors, spacing, borders, and radii reference tokens. Use `.panel` as the new neutral surface class and keep `.card` as an alias during migration:
```css
.panel,
.card {
background: var(--color-surface);
border: var(--line);
border-radius: var(--radius-m);
padding: var(--space-l);
margin-bottom: var(--space-l);
}
```
- [ ] **Step 5: Run tests and verify pass**
Run: `pytest l4d2web/tests/test_pages.py::test_dashboard_is_simple_landing_page l4d2web/tests/test_pages.py::test_shell_nav_uses_main_sections l4d2web/tests/test_pages.py::test_css_tokens_define_neutral_light_and_dark_theme -q`
Expected: PASS.
- [ ] **Step 6: Commit shell and token changes**
```bash
git add l4d2web/templates/base.html l4d2web/templates/dashboard.html l4d2web/static/css/tokens.css l4d2web/static/css/layout.css l4d2web/static/css/components.css l4d2web/static/css/logs.css l4d2web/tests/test_pages.py
git commit -m "feat(l4d2-web): add neutral shell and theme tokens"
```
### Task 2: Consolidate overlays into one authenticated page
**Files:**
- Modify: `l4d2web/routes/page_routes.py`
- Modify: `l4d2web/routes/overlay_routes.py`
- Create: `l4d2web/templates/overlays.html`
- Delete: `l4d2web/templates/admin_overlays.html`
- Test: `l4d2web/tests/test_overlays.py`
- [ ] **Step 1: Replace overlay tests with the single-page contract**
Update `l4d2web/tests/test_overlays.py` so it includes these tests:
```python
from l4d2web.models import Overlay, User
def test_user_can_view_overlay_catalog(user_client_with_overlay) -> None:
response = user_client_with_overlay.get("/overlays")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "standard" in text
assert "Add overlay" not in text
def test_admin_can_view_overlay_edit_controls(admin_client) -> None:
response = admin_client.get("/overlays")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "Add overlay" in text
assert 'action="/overlays"' in text
def test_admin_can_create_overlay(admin_client) -> None:
response = admin_client.post(
"/overlays",
data={"name": "standard", "path": "/opt/l4d2/overlays/standard"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
assert response.headers["Location"] == "/overlays"
def test_overlay_path_must_be_under_root(admin_client) -> None:
response = admin_client.post(
"/overlays",
data={"name": "bad", "path": "/tmp/bad"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 400
def test_non_admin_cannot_create_overlay(user_client_with_overlay) -> None:
response = user_client_with_overlay.post(
"/overlays",
data={"name": "bad", "path": "/opt/l4d2/overlays/bad"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 403
def test_admin_can_update_and_delete_overlay(admin_client) -> None:
create = admin_client.post(
"/overlays",
data={"name": "standard", "path": "/opt/l4d2/overlays/standard"},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
update = admin_client.post(
"/overlays/1",
data={"name": "edited", "path": "/opt/l4d2/overlays/edited"},
headers={"X-CSRF-Token": "test-token"},
)
assert update.status_code == 302
delete = admin_client.post(
"/overlays/1/delete",
headers={"X-CSRF-Token": "test-token"},
)
assert delete.status_code == 302
```
Add a `user_client_with_overlay` fixture that creates a non-admin user and one overlay, sets `user_id`, and sets `csrf_token` to `test-token`.
- [ ] **Step 2: Run tests and verify failure**
Run: `pytest l4d2web/tests/test_overlays.py -q`
Expected: FAIL because the app still uses `/admin/overlays` and has no GET `/overlays` page.
- [ ] **Step 3: Move overlay form handlers to `/overlays`**
In `l4d2web/routes/overlay_routes.py`, replace the current create route and add update/delete routes:
```python
@bp.post("/overlays")
@require_admin
def create_overlay() -> Response:
name = request.form.get("name", "").strip()
raw_path = request.form.get("path", "").strip()
if not name or not raw_path:
return Response("missing fields", status=400)
try:
validated_path = validate_overlay_path(raw_path)
except ValueError as exc:
return Response(str(exc), status=400)
with session_scope() as db:
existing = db.scalar(select(Overlay).where(Overlay.name == name))
if existing is not None:
return Response("overlay already exists", status=409)
db.add(Overlay(name=name, path=str(validated_path)))
return redirect("/overlays")
@bp.post("/overlays/<int:overlay_id>")
@require_admin
def update_overlay(overlay_id: int) -> Response:
name = request.form.get("name", "").strip()
raw_path = request.form.get("path", "").strip()
if not name or not raw_path:
return Response("missing fields", status=400)
try:
validated_path = validate_overlay_path(raw_path)
except ValueError as exc:
return Response(str(exc), status=400)
with session_scope() as db:
overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id))
if overlay is None:
return Response(status=404)
overlay.name = name
overlay.path = str(validated_path)
return redirect("/overlays")
@bp.post("/overlays/<int:overlay_id>/delete")
@require_admin
def delete_overlay(overlay_id: int) -> Response:
with session_scope() as db:
overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id))
if overlay is None:
return Response(status=404)
db.delete(overlay)
return redirect("/overlays")
```
- [ ] **Step 4: Add the `/overlays` page and remove `/admin/overlays`**
In `l4d2web/routes/page_routes.py`, replace the `admin_overlays` route with:
```python
@bp.get("/overlays")
@require_login
def overlays() -> str:
with session_scope() as db:
overlays = db.scalars(select(Overlay).order_by(Overlay.name)).all()
return render_template("overlays.html", overlays=overlays)
```
Create `l4d2web/templates/overlays.html`:
```html
{% extends "base.html" %}
{% block title %}Overlays | left4me{% endblock %}
{% block content %}
<section class="panel">
<div class="page-heading">
<h1>Overlays</h1>
</div>
{% if g.user.admin %}
<form method="post" action="/overlays" class="stack form-panel">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" required></label>
<label>Path <input name="path" required placeholder="/opt/l4d2/overlays/example"></label>
<button type="submit">Add overlay</button>
</form>
{% endif %}
<table class="table">
<thead><tr><th>Name</th><th>Path</th>{% if g.user.admin %}<th>Actions</th>{% endif %}</tr></thead>
<tbody>
{% for overlay in overlays %}
<tr>
<td>{{ overlay.name }}</td>
<td class="muted">{{ overlay.path }}</td>
{% if g.user.admin %}
<td>
<form method="post" action="/overlays/{{ overlay.id }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<input name="name" value="{{ overlay.name }}" required>
<input name="path" value="{{ overlay.path }}" required>
<button type="submit">Save</button>
</form>
<form method="post" action="/overlays/{{ overlay.id }}/delete" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">Delete</button>
</form>
</td>
{% endif %}
</tr>
{% else %}
<tr><td colspan="{% if g.user.admin %}3{% else %}2{% endif %}" class="muted">No overlays configured.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}
```
Delete `l4d2web/templates/admin_overlays.html` after the new template is in place.
- [ ] **Step 5: Run tests and verify pass**
Run: `pytest l4d2web/tests/test_overlays.py -q`
Expected: PASS.
- [ ] **Step 6: Commit overlay consolidation**
```bash
git add l4d2web/routes/page_routes.py l4d2web/routes/overlay_routes.py l4d2web/templates/overlays.html l4d2web/tests/test_overlays.py
git rm l4d2web/templates/admin_overlays.html
git commit -m "feat(l4d2-web): consolidate overlay catalog page"
```
### Task 3: Add server list, server detail, and lifecycle form enqueueing
**Files:**
- Modify: `l4d2web/routes/page_routes.py`
- Modify: `l4d2web/routes/server_routes.py`
- Create: `l4d2web/templates/servers.html`
- Modify: `l4d2web/templates/server_detail.html`
- Modify: `l4d2web/static/js/sse.js`
- Test: `l4d2web/tests/test_pages.py`
- Test: `l4d2web/tests/test_servers.py`
- [ ] **Step 1: Add failing server page tests**
Append these tests to `l4d2web/tests/test_pages.py`:
```python
def test_servers_page_links_server_names(auth_client_with_server) -> None:
response = auth_client_with_server.get("/servers")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert '<a href="/servers/1">alpha</a>' in text
assert "View" not in text
assert ">details<" not in text
def test_server_detail_shows_operations_and_logs(auth_client_with_server) -> None:
response = auth_client_with_server.get("/servers/1")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "Server: alpha" in text
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 'href="/blueprints/1"' in text
assert 'data-sse-url="/servers/1/logs/stream"' in text
```
Append this test to `l4d2web/tests/test_servers.py`:
```python
def test_lifecycle_form_creates_queued_job(user_client_with_blueprints) -> None:
client, data = user_client_with_blueprints
create_response = client.post(
"/servers",
data=json.dumps({"name": "alpha", "port": 27015, "blueprint_id": data["blueprint_id"]}),
content_type="application/json",
headers={"X-CSRF-Token": "test-token"},
)
server_id = create_response.get_json()["id"]
response = client.post(
f"/servers/{server_id}/start",
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
assert response.headers["Location"] == f"/servers/{server_id}"
```
- [ ] **Step 2: Run tests and verify failure**
Run: `pytest l4d2web/tests/test_pages.py::test_servers_page_links_server_names l4d2web/tests/test_pages.py::test_server_detail_shows_operations_and_logs l4d2web/tests/test_servers.py::test_lifecycle_form_creates_queued_job -q`
Expected: FAIL because `/servers` has no GET page and lifecycle form routes do not exist.
- [ ] **Step 3: Add lifecycle form endpoint**
In `l4d2web/routes/server_routes.py`, import `redirect` and `Job`, then add:
```python
LIFECYCLE_OPERATIONS = {"initialize", "start", "stop", "delete"}
@bp.post("/servers/<int:server_id>/<operation>")
@require_login
def enqueue_server_operation(server_id: int, operation: str) -> Response:
user = current_user()
assert user is not None
if operation not in LIFECYCLE_OPERATIONS:
return Response(status=404)
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)
db.add(Job(user_id=user.id, server_id=server.id, operation=operation, state="queued"))
if operation == "start":
server.desired_state = "running"
if operation in {"stop", "delete"}:
server.desired_state = "stopped"
return redirect(f"/servers/{server_id}")
```
- [ ] **Step 4: Add server list and enrich server detail route**
In `l4d2web/routes/page_routes.py`, add imports for `Job` and `BlueprintOverlay`, then add:
```python
@bp.get("/servers")
@require_login
def servers_page() -> str:
user = current_user()
assert user is not None
with session_scope() as db:
rows = db.execute(
select(Server, BlueprintModel)
.join(BlueprintModel, BlueprintModel.id == Server.blueprint_id)
.where(Server.user_id == user.id)
.order_by(Server.name)
).all()
return render_template("servers.html", rows=rows)
```
Replace `server_detail` with a version that loads server, blueprint, ordered overlay names, decoded arguments/config, and the newest job:
```python
@bp.get("/servers/<int:server_id>")
@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)
blueprint = db.scalar(select(BlueprintModel).where(BlueprintModel.id == server.blueprint_id))
overlay_rows = db.execute(
select(Overlay.name)
.join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
.where(BlueprintOverlay.blueprint_id == server.blueprint_id)
.order_by(BlueprintOverlay.position)
).all()
latest_job = db.scalar(
select(Job)
.where(Job.server_id == server.id)
.order_by(Job.created_at.desc())
.limit(1)
)
return render_template(
"server_detail.html",
server=server,
blueprint=blueprint,
overlay_names=[row[0] for row in overlay_rows],
arguments=json.loads(blueprint.arguments) if blueprint is not None else [],
config_lines=json.loads(blueprint.config) if blueprint is not None else [],
latest_job=latest_job,
)
```
- [ ] **Step 5: Add server templates and generic SSE binding**
Create `l4d2web/templates/servers.html`:
```html
{% extends "base.html" %}
{% block title %}Servers | left4me{% endblock %}
{% block content %}
<section class="panel">
<h1>Servers</h1>
<table class="table">
<thead><tr><th>Name</th><th>Port</th><th>Blueprint</th><th>Desired</th><th>Actual</th></tr></thead>
<tbody>
{% for server, blueprint in rows %}
<tr>
<td><a href="/servers/{{ server.id }}">{{ server.name }}</a></td>
<td>{{ server.port }}</td>
<td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td>
<td>{{ server.desired_state }}</td>
<td>{{ server.actual_state }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">No servers configured.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}
```
Replace `l4d2web/templates/server_detail.html` with:
```html
{% extends "base.html" %}
{% block title %}Server {{ server.name }} | left4me{% endblock %}
{% block content %}
<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 %}
<form method="post" action="/servers/{{ server.id }}/delete" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">delete</button>
</form>
</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>
<section class="panel">
<h2>Blueprint</h2>
<h3>Overlay order</h3>
<ol>
{% for name in overlay_names %}<li>{{ name }}</li>{% else %}<li class="muted">No overlays configured.</li>{% endfor %}
</ol>
<h3>Arguments</h3>
<pre class="code-block">{{ arguments | join('\n') }}</pre>
<h3>Config</h3>
<pre class="code-block">{{ config_lines | join('\n') }}</pre>
</section>
<section class="panel">
<h2>Current / Recent Job</h2>
{% if latest_job %}
<table class="definition-table"><tbody><tr><th>Operation</th><td>{{ latest_job.operation }}</td></tr><tr><th>State</th><td>{{ latest_job.state }}</td></tr></tbody></table>
<pre class="log-stream" data-sse-url="/jobs/{{ latest_job.id }}/stream"></pre>
{% else %}
<p class="muted">No jobs have run for this server.</p>
{% endif %}
</section>
<section class="panel">
<h2>Server Log</h2>
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
</section>
{% endblock %}
```
Update `l4d2web/static/js/sse.js` so it binds every element with `data-sse-url`:
```javascript
function streamTextToElement(element) {
const url = element.dataset.sseUrl;
if (!url) {
return;
}
const source = new EventSource(url);
source.onmessage = (event) => {
element.textContent += `${event.data}\n`;
element.scrollTop = element.scrollHeight;
};
}
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("[data-sse-url]").forEach(streamTextToElement);
});
```
- [ ] **Step 6: Run tests and verify pass**
Run: `pytest l4d2web/tests/test_pages.py::test_servers_page_links_server_names l4d2web/tests/test_pages.py::test_server_detail_shows_operations_and_logs l4d2web/tests/test_servers.py::test_lifecycle_form_creates_queued_job -q`
Expected: PASS.
- [ ] **Step 7: Commit server pages**
```bash
git add l4d2web/routes/page_routes.py l4d2web/routes/server_routes.py l4d2web/templates/servers.html l4d2web/templates/server_detail.html l4d2web/static/js/sse.js l4d2web/tests/test_pages.py l4d2web/tests/test_servers.py
git commit -m "feat(l4d2-web): add server pages and lifecycle forms"
```
### Task 4: Add blueprint list and form-based ordered overlay editing
**Files:**
- Modify: `l4d2web/routes/page_routes.py`
- Modify: `l4d2web/routes/blueprint_routes.py`
- Modify: `l4d2web/templates/blueprints.html`
- Create: `l4d2web/templates/blueprint_detail.html`
- Test: `l4d2web/tests/test_pages.py`
- Test: `l4d2web/tests/test_blueprints.py`
- [ ] **Step 1: Add failing blueprint page and form tests**
Append these tests to `l4d2web/tests/test_pages.py`:
```python
def test_blueprint_pages_fixture_has_ordered_overlay_data(auth_client_with_server) -> None:
response = auth_client_with_server.get("/blueprints/1")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "standard" in text
def test_blueprints_page_links_blueprint_names(auth_client_with_server) -> None:
response = auth_client_with_server.get("/blueprints")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert '<a href="/blueprints/1">default</a>' in text
assert "View" not in text
def test_blueprint_detail_has_ordered_overlay_form(auth_client_with_server) -> None:
response = auth_client_with_server.get("/blueprints/1")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "Overlay order matters" in text
assert 'name="arguments"' in text
assert 'name="config"' in text
assert 'name="overlay_ids"' in text
assert 'name="overlay_position_1"' in text
```
Update the `auth_client_with_server` fixture in `l4d2web/tests/test_pages.py` so it imports `BlueprintOverlay` and `Overlay`, creates one overlay named `standard`, and links it to blueprint `default` with position `0`.
Append this test to `l4d2web/tests/test_blueprints.py`:
```python
def test_form_update_preserves_ordered_overlays_and_multiline_fields(user_client) -> None:
create = user_client.post(
"/blueprints",
data={
"name": "comp",
"arguments": "-tickrate 100",
"config": "sv_consistency 1",
"overlay_ids": ["2", "1"],
"overlay_position_1": "2",
"overlay_position_2": "1",
},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
update = user_client.post(
"/blueprints/1",
data={
"name": "edited",
"arguments": "-tickrate 100\n+sv_lan 0",
"config": "sv_consistency 1\nsv_allow_lobby_connect_only 0",
"overlay_ids": ["1", "2"],
"overlay_position_1": "1",
"overlay_position_2": "2",
},
headers={"X-CSRF-Token": "test-token"},
)
assert update.status_code == 302
assert update.headers["Location"] == "/blueprints/1"
```
- [ ] **Step 2: Run tests and verify failure**
Run: `pytest l4d2web/tests/test_pages.py::test_blueprint_pages_fixture_has_ordered_overlay_data l4d2web/tests/test_pages.py::test_blueprints_page_links_blueprint_names l4d2web/tests/test_pages.py::test_blueprint_detail_has_ordered_overlay_form l4d2web/tests/test_blueprints.py::test_form_update_preserves_ordered_overlays_and_multiline_fields -q`
Expected: FAIL because `/blueprints` has no GET page and the blueprint routes accept JSON only.
- [ ] **Step 3: Add form helpers to blueprint routes**
In `l4d2web/routes/blueprint_routes.py`, add helpers:
```python
def split_textarea_lines(raw: str) -> list[str]:
return [line.strip() for line in raw.splitlines() if line.strip()]
def ordered_overlay_ids_from_form() -> list[int]:
ordered = []
for fallback_position, value in enumerate(request.form.getlist("overlay_ids")):
if not value:
continue
overlay_id = int(value)
raw_position = request.form.get(f"overlay_position_{overlay_id}", "").strip()
try:
position = int(raw_position)
except ValueError:
position = fallback_position + 1
ordered.append((position, fallback_position, overlay_id))
return [overlay_id for _, _, overlay_id in sorted(ordered)]
def replace_blueprint_overlays(db, blueprint_id: int, overlay_ids: list[int]) -> None:
db.execute(delete(BlueprintOverlay).where(BlueprintOverlay.blueprint_id == blueprint_id))
for position, overlay_id in enumerate(overlay_ids):
db.add(BlueprintOverlay(blueprint_id=blueprint_id, overlay_id=overlay_id, position=position))
```
- [ ] **Step 4: Support form creates and retain JSON only if useful**
Change `create_blueprint` so form requests redirect to the new detail page. The implementation below also keeps JSON requests returning `201` because the draft tests already cover that path, but JSON support is not a UI compatibility requirement:
```python
@bp.post("/blueprints")
@require_login
def create_blueprint() -> Response:
user = current_user()
assert user is not None
if request.is_json:
payload = request.get_json(silent=True) or {}
name = str(payload.get("name", "")).strip()
arguments = [str(item) for item in payload.get("arguments", [])]
config = [str(item) for item in payload.get("config", [])]
overlay_ids = [int(item) for item in payload.get("overlay_ids", [])]
json_response = True
else:
name = request.form.get("name", "").strip()
arguments = split_textarea_lines(request.form.get("arguments", ""))
config = split_textarea_lines(request.form.get("config", ""))
overlay_ids = ordered_overlay_ids_from_form()
json_response = False
if not name:
return Response("name is required", status=400)
with session_scope() as db:
blueprint = BlueprintModel(user_id=user.id, name=name, arguments=json.dumps(arguments), config=json.dumps(config))
db.add(blueprint)
db.flush()
replace_blueprint_overlays(db, blueprint.id, overlay_ids)
blueprint_id = blueprint.id
if json_response:
return jsonify({"id": blueprint_id}), 201
return redirect(f"/blueprints/{blueprint_id}")
```
- [ ] **Step 5: Add form update route for blueprint details**
Add this route to `l4d2web/routes/blueprint_routes.py`:
```python
@bp.post("/blueprints/<int:blueprint_id>")
@require_login
def update_blueprint_form(blueprint_id: int) -> Response:
user = current_user()
assert user is not None
name = request.form.get("name", "").strip()
if not name:
return Response("name is required", status=400)
with session_scope() as db:
blueprint = db.scalar(
select(BlueprintModel).where(BlueprintModel.id == blueprint_id, BlueprintModel.user_id == user.id)
)
if blueprint is None:
return Response(status=404)
blueprint.name = name
blueprint.arguments = json.dumps(split_textarea_lines(request.form.get("arguments", "")))
blueprint.config = json.dumps(split_textarea_lines(request.form.get("config", "")))
replace_blueprint_overlays(db, blueprint.id, ordered_overlay_ids_from_form())
return redirect(f"/blueprints/{blueprint_id}")
```
- [ ] **Step 6: Add blueprint list route and detail data**
In `l4d2web/routes/page_routes.py`, add a `/blueprints` route:
```python
@bp.get("/blueprints")
@require_login
def blueprints_page() -> str:
user = current_user()
assert user is not None
with session_scope() as db:
blueprints = db.scalars(
select(BlueprintModel).where(BlueprintModel.user_id == user.id).order_by(BlueprintModel.name)
).all()
return render_template("blueprints.html", blueprints=blueprints)
```
Replace the existing `blueprint_page` render logic with:
```python
@bp.get("/blueprints/<int:blueprint_id>")
@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)
selected_overlays = db.scalars(
select(Overlay)
.join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
.where(BlueprintOverlay.blueprint_id == blueprint.id)
.order_by(BlueprintOverlay.position)
).all()
position_rows = db.execute(
select(BlueprintOverlay.overlay_id, BlueprintOverlay.position)
.where(BlueprintOverlay.blueprint_id == blueprint.id)
).all()
all_overlays = db.scalars(select(Overlay).order_by(Overlay.name)).all()
overlay_positions = {overlay_id: position + 1 for overlay_id, position in position_rows}
return render_template(
"blueprint_detail.html",
blueprint=blueprint,
selected_overlays=selected_overlays,
all_overlays=all_overlays,
selected_overlay_ids={overlay.id for overlay in selected_overlays},
overlay_positions=overlay_positions,
arguments=json.loads(blueprint.arguments),
config_lines=json.loads(blueprint.config),
)
```
- [ ] **Step 7: Create blueprint templates**
Replace `l4d2web/templates/blueprints.html` with:
```html
{% extends "base.html" %}
{% block title %}Blueprints | left4me{% endblock %}
{% block content %}
<section class="panel">
<h1>Blueprints</h1>
<form method="post" action="/blueprints" class="stack form-panel">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" required></label>
<label>Arguments <textarea name="arguments"></textarea></label>
<label>Config <textarea name="config"></textarea></label>
<button type="submit">Create blueprint</button>
</form>
<table class="table">
<thead><tr><th>Name</th><th>Created</th><th>Updated</th><th>Actions</th></tr></thead>
<tbody>
{% for blueprint in blueprints %}
<tr>
<td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td>
<td>{{ blueprint.created_at }}</td>
<td>{{ blueprint.updated_at }}</td>
<td>
<form method="post" action="/blueprints/{{ blueprint.id }}/delete" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4" class="muted">No blueprints configured.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}
```
Create `l4d2web/templates/blueprint_detail.html` with:
```html
{% extends "base.html" %}
{% block title %}Blueprint {{ blueprint.name }} | left4me{% endblock %}
{% block content %}
<section class="panel">
<h1>Blueprint: {{ blueprint.name }}</h1>
<form method="post" action="/blueprints/{{ blueprint.id }}" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" value="{{ blueprint.name }}" required></label>
<p class="muted">Overlay order matters: the first overlay has highest precedence.</p>
<table class="table">
<thead><tr><th>Use</th><th>Order</th><th>Overlay</th></tr></thead>
<tbody>
{% for overlay in all_overlays %}
<tr>
<td><input type="checkbox" name="overlay_ids" value="{{ overlay.id }}" {% if overlay.id in selected_overlay_ids %}checked{% endif %}></td>
<td><input class="position-input" name="overlay_position_{{ overlay.id }}" value="{{ overlay_positions.get(overlay.id, '') }}" inputmode="numeric"></td>
<td>{{ overlay.name }}</td>
</tr>
{% else %}
<tr><td colspan="3" class="muted">No overlays available.</td></tr>
{% endfor %}
</tbody>
</table>
<label>Arguments <textarea name="arguments">{{ arguments | join('\n') }}</textarea></label>
<label>Config <textarea name="config">{{ config_lines | join('\n') }}</textarea></label>
<button type="submit">Save blueprint</button>
</form>
</section>
{% endblock %}
```
- [ ] **Step 8: Run tests and verify pass**
Run: `pytest l4d2web/tests/test_pages.py::test_blueprint_pages_fixture_has_ordered_overlay_data l4d2web/tests/test_pages.py::test_blueprints_page_links_blueprint_names l4d2web/tests/test_pages.py::test_blueprint_detail_has_ordered_overlay_form l4d2web/tests/test_blueprints.py::test_form_update_preserves_ordered_overlays_and_multiline_fields -q`
Expected: PASS.
- [ ] **Step 9: Commit blueprint pages**
```bash
git add l4d2web/routes/page_routes.py l4d2web/routes/blueprint_routes.py l4d2web/templates/blueprints.html l4d2web/templates/blueprint_detail.html l4d2web/tests/test_pages.py l4d2web/tests/test_blueprints.py
git commit -m "feat(l4d2-web): add form-based blueprint editor"
```
### Task 5: Add admin landing, users, and jobs pages
**Files:**
- Modify: `l4d2web/routes/page_routes.py`
- Create: `l4d2web/templates/admin.html`
- Create: `l4d2web/templates/admin_users.html`
- Create: `l4d2web/templates/admin_jobs.html`
- Test: `l4d2web/tests/test_pages.py`
- [ ] **Step 1: Add failing admin page tests**
Append these tests to `l4d2web/tests/test_pages.py`:
```python
def test_non_admin_does_not_see_admin_nav(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 'href="/admin"' not in text
def test_admin_can_use_admin_pages(tmp_path, monkeypatch) -> None:
db_url = f"sqlite:///{tmp_path/'admin-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:
admin = User(username="admin", password_digest=hash_password("secret"), admin=True)
session.add(admin)
session.flush()
admin_id = admin.id
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = admin_id
assert client.get("/admin").status_code == 200
assert client.get("/admin/users").status_code == 200
assert client.get("/admin/jobs").status_code == 200
assert 'href="/admin"' in client.get("/dashboard").get_data(as_text=True)
def test_non_admin_cannot_open_admin_pages(auth_client_with_server) -> None:
assert auth_client_with_server.get("/admin").status_code == 403
assert auth_client_with_server.get("/admin/users").status_code == 403
assert auth_client_with_server.get("/admin/jobs").status_code == 403
```
- [ ] **Step 2: Run tests and verify failure**
Run: `pytest l4d2web/tests/test_pages.py::test_non_admin_does_not_see_admin_nav l4d2web/tests/test_pages.py::test_admin_can_use_admin_pages l4d2web/tests/test_pages.py::test_non_admin_cannot_open_admin_pages -q`
Expected: FAIL because the admin pages do not exist.
- [ ] **Step 3: Add admin page routes**
In `l4d2web/routes/page_routes.py`, add imports for `User` and `Job`, then add:
```python
@bp.get("/admin")
@require_admin
def admin_home() -> str:
return render_template("admin.html")
@bp.get("/admin/users")
@require_admin
def admin_users() -> str:
with session_scope() as db:
users = db.scalars(select(User).order_by(User.username)).all()
return render_template("admin_users.html", users=users)
@bp.get("/admin/jobs")
@require_admin
def admin_jobs() -> str:
with session_scope() as db:
rows = db.execute(
select(Job, User, Server)
.join(User, User.id == Job.user_id)
.outerjoin(Server, Server.id == Job.server_id)
.order_by(Job.created_at.desc())
).all()
return render_template("admin_jobs.html", rows=rows)
```
- [ ] **Step 4: Add admin templates**
Create `l4d2web/templates/admin.html`:
```html
{% extends "base.html" %}
{% block title %}Admin | left4me{% endblock %}
{% block content %}
<section class="panel">
<h1>Admin</h1>
<ul class="link-list">
<li><a href="/admin/users">Users</a></li>
<li><a href="/admin/jobs">Jobs</a></li>
</ul>
</section>
{% endblock %}
```
Create `l4d2web/templates/admin_users.html`:
```html
{% extends "base.html" %}
{% block title %}Admin Users | left4me{% endblock %}
{% block content %}
<section class="panel">
<h1>Users</h1>
<table class="table">
<thead><tr><th>Username</th><th>Admin</th><th>Created</th><th>Updated</th></tr></thead>
<tbody>
{% for user in users %}
<tr><td>{{ user.username }}</td><td>{{ "yes" if user.admin else "no" }}</td><td>{{ user.created_at }}</td><td>{{ user.updated_at }}</td></tr>
{% else %}
<tr><td colspan="4" class="muted">No users found.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}
```
Create `l4d2web/templates/admin_jobs.html`:
```html
{% extends "base.html" %}
{% block title %}Admin Jobs | left4me{% endblock %}
{% block content %}
<section class="panel">
<h1>Jobs</h1>
<table class="table">
<thead><tr><th>ID</th><th>Operation</th><th>State</th><th>User</th><th>Server</th><th>Created</th><th>Finished</th></tr></thead>
<tbody>
{% for job, user, server in rows %}
<tr>
<td>{{ job.id }}</td>
<td>{{ job.operation }}</td>
<td>{{ job.state }}</td>
<td>{{ user.username }}</td>
<td>{% if server %}<a href="/servers/{{ server.id }}">{{ server.name }}</a>{% else %}-{% endif %}</td>
<td>{{ job.created_at }}</td>
<td>{{ job.finished_at or "-" }}</td>
</tr>
{% else %}
<tr><td colspan="7" class="muted">No jobs found.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}
```
- [ ] **Step 5: Run tests and verify pass**
Run: `pytest l4d2web/tests/test_pages.py::test_non_admin_does_not_see_admin_nav l4d2web/tests/test_pages.py::test_admin_can_use_admin_pages l4d2web/tests/test_pages.py::test_non_admin_cannot_open_admin_pages -q`
Expected: PASS.
- [ ] **Step 6: Commit admin pages**
```bash
git add l4d2web/routes/page_routes.py l4d2web/templates/admin.html l4d2web/templates/admin_users.html l4d2web/templates/admin_jobs.html l4d2web/tests/test_pages.py
git commit -m "feat(l4d2-web): add admin landing and system pages"
```
### Task 6: Update stale color-contract docs and run full web test suite
**Files:**
- Modify: `AGENTS.md`
- Modify: `docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md`
- Test: `l4d2web/tests/*.py`
- [ ] **Step 1: Update stale fixed-link-color wording**
In `AGENTS.md`, replace:
```markdown
- Custom CSS with consistent link color `#0F766E`.
```
with:
```markdown
- Custom CSS with tokenized, consistent link and accent colors.
```
In `docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md`, replace:
```markdown
- consistent link color `#0F766E`
```
with:
```markdown
- tokenized, consistent link and accent colors
```
Also update the sample CSS token in that plan from `--color-link: #0F766E;` to:
```css
--color-link: var(--color-primary);
```
- [ ] **Step 2: Verify stale exact color contract is gone from active guidance**
Run: `rg '#0F766E|0F766E' AGENTS.md docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md l4d2web/static/css`
Expected: no matches.
- [ ] **Step 3: Run the focused UI test files**
Run: `pytest l4d2web/tests/test_pages.py l4d2web/tests/test_overlays.py l4d2web/tests/test_blueprints.py l4d2web/tests/test_servers.py -q`
Expected: PASS.
- [ ] **Step 4: Run the full web test suite**
Run: `pytest l4d2web/tests -q`
Expected: PASS.
- [ ] **Step 5: Commit docs and final verification updates**
```bash
git add AGENTS.md docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md
git commit -m "docs(l4d2-web): update ui color contract"
```
---
## Self-Review
- [ ] Spec coverage: shell, dashboard landing text, single overlay page, linked table names, blueprint ordered overlays, server detail page, admin pages, standard forms, SSE logs, no WebSockets, neutral light/dark tokens.
- [ ] No external frontend dependencies are introduced.
- [ ] No inline JavaScript is introduced.
- [ ] Draft JSON route tests are either intentionally retained or replaced by form-based tests.
- [ ] Access rules are covered for normal users and admins.
- [ ] Docs are updated to remove the fixed `#0F766E` contract.
- [ ] Final verification command is `pytest l4d2web/tests -q`.