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.auth import current_user, require_admin, require_login
|
||||||
from l4d2web.db import session_scope
|
from l4d2web.db import session_scope
|
||||||
from l4d2web.models import Blueprint as BlueprintModel
|
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__)
|
bp = Blueprint("pages", __name__)
|
||||||
|
|
@ -18,6 +18,56 @@ def dashboard() -> str:
|
||||||
return render_template("dashboard.html")
|
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")
|
@bp.get("/overlays")
|
||||||
@require_login
|
@require_login
|
||||||
def overlays() -> str:
|
def overlays() -> str:
|
||||||
|
|
@ -54,17 +104,3 @@ def blueprint_page(blueprint_id: int):
|
||||||
config_lines=json.loads(blueprint.config),
|
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 sqlalchemy import select
|
||||||
|
|
||||||
from l4d2web.auth import current_user, require_login
|
from l4d2web.auth import current_user, require_login
|
||||||
from l4d2web.db import session_scope
|
from l4d2web.db import session_scope
|
||||||
from l4d2web.models import Blueprint as BlueprintModel
|
from l4d2web.models import Blueprint as BlueprintModel
|
||||||
from l4d2web.models import Server
|
from l4d2web.models import Job, Server
|
||||||
|
|
||||||
|
|
||||||
bp = Blueprint("server", __name__)
|
bp = Blueprint("server", __name__)
|
||||||
|
|
@ -67,3 +67,27 @@ def update_server(server_id: int) -> Response:
|
||||||
server.blueprint_id = blueprint.id
|
server.blueprint_id = blueprint.id
|
||||||
|
|
||||||
return jsonify({"id": server_id}), 200
|
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) {
|
function streamTextToElement(element) {
|
||||||
const target = document.getElementById(elementId);
|
const url = element.dataset.sseUrl;
|
||||||
if (!target) {
|
if (!url) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = new EventSource(url);
|
const source = new EventSource(url);
|
||||||
source.onmessage = (event) => {
|
source.onmessage = (event) => {
|
||||||
target.textContent += `${event.data}\n`;
|
element.textContent += `${event.data}\n`;
|
||||||
target.scrollTop = target.scrollHeight;
|
element.scrollTop = element.scrollHeight;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const serverLog = document.getElementById("server-log-stream");
|
document.querySelectorAll("[data-sse-url]").forEach(streamTextToElement);
|
||||||
if (serverLog) {
|
|
||||||
streamTextToElement(serverLog.dataset.serverLogUrl, "server-log-stream");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,59 @@
|
||||||
{% block title %}Server {{ server.name }} | left4me{% endblock %}
|
{% block title %}Server {{ server.name }} | left4me{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="card">
|
<section class="panel">
|
||||||
<h1>Server: {{ server.name }}</h1>
|
<div class="page-heading">
|
||||||
<p><strong>Port:</strong> {{ server.port }}</p>
|
<h1>Server: {{ server.name }}</h1>
|
||||||
<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>
|
||||||
|
|
||||||
<section class="card">
|
<section class="panel">
|
||||||
<h2>Live Logs</h2>
|
<h2>Blueprint</h2>
|
||||||
<pre id="server-log-stream" class="log-stream" data-server-log-url="/servers/{{ server.id }}/logs/stream"></pre>
|
<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>
|
</section>
|
||||||
{% endblock %}
|
{% 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()
|
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:
|
def test_blueprint_page_private_visibility(user_client_and_other_blueprint) -> None:
|
||||||
client, blueprint_id = user_client_and_other_blueprint
|
client, blueprint_id = user_client_and_other_blueprint
|
||||||
response = client.get(f"/blueprints/{blueprint_id}")
|
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"},
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
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