diff --git a/l4d2web/routes/page_routes.py b/l4d2web/routes/page_routes.py index cdc312c..220d29a 100644 --- a/l4d2web/routes/page_routes.py +++ b/l4d2web/routes/page_routes.py @@ -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/") +@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/") -@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) - diff --git a/l4d2web/routes/server_routes.py b/l4d2web/routes/server_routes.py index 86a4d9a..78636b0 100644 --- a/l4d2web/routes/server_routes.py +++ b/l4d2web/routes/server_routes.py @@ -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//") +@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}") diff --git a/l4d2web/static/js/sse.js b/l4d2web/static/js/sse.js index 458b0d9..f023ba3 100644 --- a/l4d2web/static/js/sse.js +++ b/l4d2web/static/js/sse.js @@ -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); }); diff --git a/l4d2web/templates/server_detail.html b/l4d2web/templates/server_detail.html index 1c52d12..2530393 100644 --- a/l4d2web/templates/server_detail.html +++ b/l4d2web/templates/server_detail.html @@ -3,14 +3,59 @@ {% block title %}Server {{ server.name }} | left4me{% endblock %} {% block content %} -
-

Server: {{ server.name }}

-

Port: {{ server.port }}

-

Desired: {{ server.desired_state }} | Actual: {{ server.actual_state }}

+
+
+

Server: {{ server.name }}

+
+ {% for operation in ["initialize", "start", "stop"] %} +
+ + +
+ {% endfor %} +
+ + +
+
+
+ + + + + + + + + + +
Name{{ server.name }}
Port{{ server.port }}
Blueprint{% if blueprint %}{{ blueprint.name }}{% endif %}
Desired state{{ server.desired_state }}
Actual state{{ server.actual_state }}
Last error{{ server.last_error or "-" }}
-
-

Live Logs

-

+
+

Blueprint

+

Overlay order

+
    + {% for name in overlay_names %}
  1. {{ name }}
  2. {% else %}
  3. No overlays configured.
  4. {% endfor %} +
+

Arguments

+
{{ arguments | join('\n') }}
+

Config

+
{{ config_lines | join('\n') }}
+
+ +
+

Current / Recent Job

+ {% if latest_job %} +
Operation{{ latest_job.operation }}
State{{ latest_job.state }}
+

+  {% else %}
+  

No jobs have run for this server.

+ {% endif %} +
+ +
+

Server Log

+

 
{% endblock %} diff --git a/l4d2web/templates/servers.html b/l4d2web/templates/servers.html new file mode 100644 index 0000000..900da6a --- /dev/null +++ b/l4d2web/templates/servers.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block title %}Servers | left4me{% endblock %} + +{% block content %} +
+

Servers

+ + + + {% for server, blueprint in rows %} + + + + + + + + {% else %} + + {% endfor %} + +
NamePortBlueprintDesiredActual
{{ server.name }}{{ server.port }}{{ blueprint.name }}{{ server.desired_state }}{{ server.actual_state }}
No servers configured.
+
+{% endblock %} diff --git a/l4d2web/tests/test_pages.py b/l4d2web/tests/test_pages.py index b75a585..e9c7ee4 100644 --- a/l4d2web/tests/test_pages.py +++ b/l4d2web/tests/test_pages.py @@ -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 'alpha' 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}") diff --git a/l4d2web/tests/test_servers.py b/l4d2web/tests/test_servers.py index 38d7b90..6b52e52 100644 --- a/l4d2web/tests/test_servers.py +++ b/l4d2web/tests/test_servers.py @@ -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}"