docs(l4d2-web): finalize blueprint-driven ui and deployment contracts

This commit is contained in:
mwiegand 2026-04-23 01:23:17 +02:00
parent ec74563705
commit d76d72f37e
No known key found for this signature in database
16 changed files with 444 additions and 0 deletions

View file

@ -1 +1,28 @@
# l4d2-web-app
Flask web app for managing L4D2 servers through user-private blueprints.
## Key v1 behaviors
- Public signup/login with local username/password
- Admin-managed overlay catalog
- Private blueprints per user
- Server creation from blueprints (live-linked; no per-server blueprint overrides)
- Async job model with persisted command logs in `job_logs`
- Desired vs actual state model
- Live logs for jobs and servers via SSE endpoints
## Frontend constraints
- Server-rendered templates (Jinja)
- Vendored HTMX (`static/vendor/htmx.min.js`)
- Custom CSS only
- Consistent link color: `#0F766E`
## Development
```bash
python3 -m venv .venv
.venv/bin/pip install -e .
.venv/bin/pytest tests -q
```

View file

@ -13,6 +13,7 @@ from l4d2web.routes.auth_routes import reset_login_rate_limits
from l4d2web.routes.job_routes import bp as job_bp
from l4d2web.routes.log_routes import bp as log_bp
from l4d2web.routes.overlay_routes import bp as overlay_bp
from l4d2web.routes.page_routes import bp as page_bp
from l4d2web.routes.server_routes import bp as server_bp
from l4d2web.services.job_worker import recover_stale_jobs
@ -54,6 +55,7 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
app.register_blueprint(server_bp)
app.register_blueprint(job_bp)
app.register_blueprint(log_bp)
app.register_blueprint(page_bp)
register_cli(app)
if app.config.get("TESTING"):
reset_login_rate_limits()

View file

@ -0,0 +1,78 @@
import json
from flask import Blueprint, Response, current_app, render_template
from sqlalchemy import select
from l4d2web.auth import current_user, require_admin, require_login
from l4d2web.db import session_scope
from l4d2web.models import Blueprint as BlueprintModel
from l4d2web.models import BlueprintOverlay, Overlay, Server
bp = Blueprint("pages", __name__)
@bp.get("/dashboard")
@require_login
def dashboard() -> str:
user = current_user()
assert user is not None
with session_scope() as db:
servers = db.scalars(select(Server).where(Server.user_id == user.id).order_by(Server.name)).all()
return render_template(
"dashboard.html",
servers=servers,
refresh_seconds=current_app.config["STATUS_REFRESH_SECONDS"],
)
@bp.get("/blueprints/<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)
overlay_rows = db.execute(
select(Overlay.name)
.join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
.where(BlueprintOverlay.blueprint_id == blueprint.id)
.order_by(BlueprintOverlay.position)
).all()
return render_template(
"blueprints.html",
blueprint=blueprint,
overlay_names=[row[0] for row in overlay_rows],
arguments=json.loads(blueprint.arguments),
config_lines=json.loads(blueprint.config),
)
@bp.get("/servers/<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)
return render_template("server_detail.html", server=server)
@bp.get("/admin/overlays")
@require_admin
def admin_overlays() -> str:
with session_scope() as db:
overlays = db.scalars(select(Overlay).order_by(Overlay.name)).all()
return render_template("admin_overlays.html", overlays=overlays)

View file

@ -0,0 +1,45 @@
.card {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: var(--space-4);
margin-bottom: var(--space-4);
box-shadow: 0 8px 20px #0F766E12;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
text-align: left;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--color-border);
}
.muted {
color: var(--color-muted);
}
.stack {
display: grid;
gap: var(--space-3);
}
input,
button,
select,
textarea {
font: inherit;
}
button {
background: var(--color-link);
border: none;
border-radius: 8px;
color: #fff;
padding: var(--space-2) var(--space-4);
cursor: pointer;
}

View file

@ -0,0 +1,38 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Segoe UI", "Helvetica Neue", sans-serif;
background: radial-gradient(circle at top right, #DDEFEA, #F3F7F6 45%);
color: var(--color-text);
}
.site-header {
background: #FFFFFFD9;
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
backdrop-filter: blur(6px);
}
.site-header-inner {
max-width: 960px;
margin: 0 auto;
padding: var(--space-4);
display: flex;
justify-content: space-between;
align-items: center;
}
.brand {
font-weight: 700;
text-decoration: none;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: var(--space-6) var(--space-4) var(--space-6);
}

View file

@ -0,0 +1,10 @@
.log-stream {
min-height: 180px;
max-height: 360px;
overflow: auto;
background: #0A1412;
color: #CCE9E1;
border-radius: 8px;
padding: var(--space-3);
font-family: "SFMono-Regular", Menlo, Consolas, monospace;
}

View file

@ -0,0 +1,17 @@
:root {
--color-link: #0F766E;
--color-bg: #F3F7F6;
--color-text: #11201D;
--color-card: #FFFFFF;
--color-border: #D4E4DF;
--color-muted: #4A6A63;
--radius: 10px;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
}
a {
color: var(--color-link);
}

View file

@ -0,0 +1,10 @@
document.addEventListener("DOMContentLoaded", () => {
const token = document.querySelector("meta[name='csrf-token']")?.getAttribute("content");
if (!token || !window.htmx || !window.htmx.on) {
return;
}
window.htmx.on("htmx:configRequest", (event) => {
event.detail.headers["X-CSRF-Token"] = token;
});
});

View file

@ -0,0 +1,19 @@
function streamTextToElement(url, elementId) {
const target = document.getElementById(elementId);
if (!target) {
return;
}
const source = new EventSource(url);
source.onmessage = (event) => {
target.textContent += `${event.data}\n`;
target.scrollTop = target.scrollHeight;
};
}
document.addEventListener("DOMContentLoaded", () => {
const serverLog = document.getElementById("server-log-stream");
if (serverLog) {
streamTextToElement(serverLog.dataset.serverLogUrl, "server-log-stream");
}
});

View file

@ -0,0 +1 @@
window.htmx = window.htmx || {};

View file

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}Admin Overlays | left4me{% endblock %}
{% block content %}
<section class="card">
<h1>Overlay Catalog</h1>
<form method="post" action="/admin/overlays" class="stack">
<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>
<h2>Known overlays</h2>
<ul>
{% for overlay in overlays %}
<li><strong>{{ overlay.name }}</strong> <span class="muted">{{ overlay.path }}</span></li>
{% else %}
<li class="muted">No overlays configured.</li>
{% endfor %}
</ul>
</section>
{% endblock %}

View file

@ -0,0 +1,30 @@
<!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">
<a class="brand" href="/dashboard">left4me</a>
<nav>
<a href="/dashboard">Dashboard</a>
<a href="/admin/overlays">Overlays</a>
</nav>
</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>

View file

@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Blueprint | left4me{% endblock %}
{% block content %}
<section class="card">
<h1>Blueprint: {{ blueprint.name }}</h1>
<h2>Overlays</h2>
<ul>
{% for name in overlay_names %}
<li>{{ name }}</li>
{% else %}
<li class="muted">No overlays configured.</li>
{% endfor %}
</ul>
<h2>Arguments</h2>
<pre>{{ arguments | join('\n') }}</pre>
<h2>Config</h2>
<pre>{{ config_lines | join('\n') }}</pre>
</section>
{% endblock %}

View file

@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block title %}Dashboard | left4me{% endblock %}
{% block content %}
<section class="card">
<h1>Dashboard</h1>
<p class="muted">Status refresh every {{ refresh_seconds }}s.</p>
<table class="table">
<thead>
<tr><th>Name</th><th>Port</th><th>Desired</th><th>Actual</th><th></th></tr>
</thead>
<tbody>
{% for server in servers %}
<tr>
<td>{{ server.name }}</td>
<td>{{ server.port }}</td>
<td>{{ server.desired_state }}</td>
<td>{{ server.actual_state }}</td>
<td><a href="/servers/{{ server.id }}">View</a></td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">No servers yet.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View file

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Server {{ server.name }} | left4me{% endblock %}
{% block content %}
<section class="card">
<h1>Server: {{ server.name }}</h1>
<p><strong>Port:</strong> {{ server.port }}</p>
<p><strong>Desired:</strong> {{ server.desired_state }} | <strong>Actual:</strong> {{ server.actual_state }}</p>
</section>
<section class="card">
<h2>Live Logs</h2>
<pre id="server-log-stream" class="log-stream" data-server-log-url="/servers/{{ server.id }}/logs/stream"></pre>
</section>
{% endblock %}

View file

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