feat(l4d2-web): add server pages and lifecycle forms

This commit is contained in:
mwiegand 2026-05-06 12:08:19 +02:00
parent 6559cf314e
commit 71004a9deb
No known key found for this signature in database
7 changed files with 203 additions and 33 deletions

View file

@ -6,7 +6,7 @@ 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
from l4d2web.models import BlueprintOverlay, Job, Overlay, Server
bp = Blueprint("pages", __name__)
@ -18,6 +18,56 @@ def dashboard() -> str:
return render_template("dashboard.html")
@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)
@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,
)
@bp.get("/overlays")
@require_login
def overlays() -> str:
@ -54,17 +104,3 @@ def blueprint_page(blueprint_id: int):
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)

View file

@ -1,10 +1,10 @@
from flask import Blueprint, Response, jsonify, request
from flask import Blueprint, Response, jsonify, redirect, request
from sqlalchemy import select
from l4d2web.auth import current_user, require_login
from l4d2web.db import session_scope
from l4d2web.models import Blueprint as BlueprintModel
from l4d2web.models import Server
from l4d2web.models import Job, Server
bp = Blueprint("server", __name__)
@ -67,3 +67,27 @@ def update_server(server_id: int) -> Response:
server.blueprint_id = blueprint.id
return jsonify({"id": server_id}), 200
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}")

View file

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

View file

@ -3,14 +3,59 @@
{% block title %}Server {{ server.name }} | left4me{% endblock %}
{% block content %}
<section class="card">
<section class="panel">
<div class="page-heading">
<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>
<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="card">
<h2>Live Logs</h2>
<pre id="server-log-stream" class="log-stream" data-server-log-url="/servers/{{ server.id }}/logs/stream"></pre>
<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 %}

View file

@ -0,0 +1,25 @@
{% 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 %}

View file

@ -108,6 +108,30 @@ def test_css_tokens_define_neutral_light_and_dark_theme() -> None:
assert "radial-gradient" not in Path("l4d2web/static/css/layout.css").read_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
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_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}")

View file

@ -69,3 +69,22 @@ def test_reassign_blueprint_anytime(user_client_with_blueprints) -> None:
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 200
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}"