docs(l4d2-web): finalize blueprint-driven ui and deployment contracts
This commit is contained in:
parent
ec74563705
commit
d76d72f37e
16 changed files with 444 additions and 0 deletions
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
78
components/l4d2-web-app/src/l4d2web/routes/page_routes.py
Normal file
78
components/l4d2-web-app/src/l4d2web/routes/page_routes.py
Normal 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)
|
||||
|
|
@ -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;
|
||||
}
|
||||
38
components/l4d2-web-app/src/l4d2web/static/css/layout.css
Normal file
38
components/l4d2-web-app/src/l4d2web/static/css/layout.css
Normal 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);
|
||||
}
|
||||
10
components/l4d2-web-app/src/l4d2web/static/css/logs.css
Normal file
10
components/l4d2-web-app/src/l4d2web/static/css/logs.css
Normal 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;
|
||||
}
|
||||
17
components/l4d2-web-app/src/l4d2web/static/css/tokens.css
Normal file
17
components/l4d2-web-app/src/l4d2web/static/css/tokens.css
Normal 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);
|
||||
}
|
||||
10
components/l4d2-web-app/src/l4d2web/static/js/csrf.js
Normal file
10
components/l4d2-web-app/src/l4d2web/static/js/csrf.js
Normal 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;
|
||||
});
|
||||
});
|
||||
19
components/l4d2-web-app/src/l4d2web/static/js/sse.js
Normal file
19
components/l4d2-web-app/src/l4d2web/static/js/sse.js
Normal 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");
|
||||
}
|
||||
});
|
||||
1
components/l4d2-web-app/src/l4d2web/static/vendor/htmx.min.js
vendored
Normal file
1
components/l4d2-web-app/src/l4d2web/static/vendor/htmx.min.js
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
window.htmx = window.htmx || {};
|
||||
|
|
@ -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 %}
|
||||
30
components/l4d2-web-app/src/l4d2web/templates/base.html
Normal file
30
components/l4d2-web-app/src/l4d2web/templates/base.html
Normal 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>
|
||||
|
|
@ -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 %}
|
||||
28
components/l4d2-web-app/src/l4d2web/templates/dashboard.html
Normal file
28
components/l4d2-web-app/src/l4d2web/templates/dashboard.html
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
72
components/l4d2-web-app/tests/test_pages.py
Normal file
72
components/l4d2-web-app/tests/test_pages.py
Normal 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
|
||||
Loading…
Reference in a new issue