From 072d9f78e795d552a4d296be65b1300ab3913b51 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Tue, 12 May 2026 22:14:57 +0200 Subject: [PATCH] feat(servers): show live counts + map badge in server list Co-Authored-By: Claude Sonnet 4.6 --- l4d2web/routes/page_routes.py | 41 +++++++++++++++++++++- l4d2web/templates/servers.html | 16 +++++++-- l4d2web/tests/test_servers.py | 62 ++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/l4d2web/routes/page_routes.py b/l4d2web/routes/page_routes.py index 9fc9f37..903270d 100644 --- a/l4d2web/routes/page_routes.py +++ b/l4d2web/routes/page_routes.py @@ -1,6 +1,7 @@ import json +from datetime import UTC, datetime, timedelta -from flask import Blueprint, Response, redirect, render_template, request +from flask import Blueprint, Response, current_app, redirect, render_template, request from sqlalchemy import func, select, update from l4d2web.auth import current_user, require_admin, require_login @@ -12,6 +13,7 @@ from l4d2web.models import ( Overlay, OverlayWorkshopItem, Server, + ServerLiveState, User, WorkshopItem, ) @@ -154,6 +156,42 @@ def servers_page() -> str: select(BlueprintModel).where(BlueprintModel.user_id == user.id).order_by(BlueprintModel.name) ).all() + stale_seconds = current_app.config.get("LIVE_STATE_STALE_SECONDS", 30) + cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(seconds=stale_seconds) + + server_ids = [s.id for s, _bp in rows] + latest_rows: dict[int, ServerLiveState] = {} + if server_ids: + subq = ( + select( + ServerLiveState.server_id, + func.max(ServerLiveState.started_at).label("mx"), + ) + .where(ServerLiveState.server_id.in_(server_ids)) + .group_by(ServerLiveState.server_id) + .subquery() + ) + sls_rows = db.scalars( + select(ServerLiveState).join( + subq, + (ServerLiveState.server_id == subq.c.server_id) + & (ServerLiveState.started_at == subq.c.mx), + ) + ).all() + for r in sls_rows: + latest_rows[r.server_id] = r + + live_state_by_server: dict[int, dict] = {} + for sid, row in latest_rows.items(): + fresh = row.last_seen_at >= cutoff + live_state_by_server[sid] = { + "fresh": fresh, + "players": row.players, + "max_players": row.max_players, + "map": row.map, + "hibernating": row.hibernating, + } + prefill_blueprint_id: int | None = None raw_prefill = request.args.get("blueprint_id") if raw_prefill: @@ -169,6 +207,7 @@ def servers_page() -> str: rows=rows, blueprints=blueprints, prefill_blueprint_id=prefill_blueprint_id, + live_state_by_server=live_state_by_server, ) diff --git a/l4d2web/templates/servers.html b/l4d2web/templates/servers.html index fdc1a8f..ba4974b 100644 --- a/l4d2web/templates/servers.html +++ b/l4d2web/templates/servers.html @@ -13,18 +13,30 @@ {% endif %} - + {% for server, blueprint in rows %} + {% set ls = live_state_by_server.get(server.id) %} + {% else %} - + {% endfor %}
NamePortBlueprintDesiredActual
NamePortBlueprintDesiredActualLive
{{ server.name }} {{ server.port }} {{ blueprint.name }} {{ server.desired_state }} {{ server.actual_state }} + {% if server.actual_state != 'running' %} + + {% elif ls is none or not ls.fresh %} + ? + {% elif ls.hibernating %} + {{ ls.players }}/{{ ls.max_players }} · idle · {{ ls.map }} + {% else %} + {{ ls.players }}/{{ ls.max_players }} · {{ ls.map }} + {% endif %} +
No servers configured.
No servers configured.
diff --git a/l4d2web/tests/test_servers.py b/l4d2web/tests/test_servers.py index 2cd35a6..dcfdf29 100644 --- a/l4d2web/tests/test_servers.py +++ b/l4d2web/tests/test_servers.py @@ -446,6 +446,68 @@ def test_create_server_generates_rcon_password(user_client_with_blueprints) -> N assert len(row.rcon_password) >= 32 +def test_servers_index_renders_live_state_badge(user_client_with_blueprints) -> None: + from datetime import timedelta + + from sqlalchemy import select + + from l4d2web.models import Server, ServerLiveState + + client, data = user_client_with_blueprints + + # Seed one server with a recent snapshot, one without. + now = datetime.now(UTC).replace(tzinfo=None) + with session_scope() as db: + s_active = Server( + user_id=data["user_id"], + blueprint_id=data["blueprint_id"], + name="active", + port=27700, + rcon_password="x", + actual_state="running", + ) + s_stale = Server( + user_id=data["user_id"], + blueprint_id=data["blueprint_id"], + name="stale", + port=27701, + rcon_password="x", + actual_state="running", + ) + db.add_all([s_active, s_stale]) + db.flush() + db.add( + ServerLiveState( + server_id=s_active.id, + started_at=now, + last_seen_at=now, + players=2, + max_players=4, + bots=0, + map="c1m2_streets", + hibernating=False, + ) + ) + old = now - timedelta(minutes=5) + db.add( + ServerLiveState( + server_id=s_stale.id, + started_at=old, + last_seen_at=old, + players=0, + max_players=4, + bots=0, + map="c1m1_hotel", + hibernating=True, + ) + ) + + res = client.get("/servers") + html = res.get_data(as_text=True) + assert "2/4" in html + assert "c1m2_streets" in html + + def test_reset_operation_enqueues_job_and_stops_desired_state(user_client_with_blueprints) -> None: from sqlalchemy import select