1376 lines
48 KiB
Markdown
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`.
|