feat(l4d2-web): add server pages and lifecycle forms
This commit is contained in:
parent
6559cf314e
commit
71004a9deb
7 changed files with 203 additions and 33 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,14 +3,59 @@
|
|||
{% 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 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="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 %}
|
||||
|
|
|
|||
25
l4d2web/templates/servers.html
Normal file
25
l4d2web/templates/servers.html
Normal 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 %}
|
||||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
Loading…
Reference in a new issue