From 9aaa26d9a9950862ca6cb59632dc1b83b5a4870b Mon Sep 17 00:00:00 2001 From: mwiegand Date: Tue, 12 May 2026 22:20:01 +0200 Subject: [PATCH] feat(servers): add live-state panel with current and recent players HTMX-refreshed /servers//live-state fragment renders snapshot summary, current players with avatars/ping, and recent-player history; server_detail.html bootstraps it via hx-trigger="load". Co-Authored-By: Claude Sonnet 4.6 --- l4d2web/routes/server_routes.py | 88 +++++++++++++++++++++++++++- l4d2web/templates/_live_state.html | 57 ++++++++++++++++++ l4d2web/templates/server_detail.html | 6 ++ l4d2web/tests/test_servers.py | 58 ++++++++++++++++++ 4 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 l4d2web/templates/_live_state.html diff --git a/l4d2web/routes/server_routes.py b/l4d2web/routes/server_routes.py index 7a6d02c..dd0865b 100644 --- a/l4d2web/routes/server_routes.py +++ b/l4d2web/routes/server_routes.py @@ -1,13 +1,14 @@ import secrets +from datetime import UTC, datetime, timedelta -from flask import Blueprint, Response, current_app, jsonify, redirect, request -from sqlalchemy import select +from flask import Blueprint, Response, current_app, jsonify, redirect, render_template, request +from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError 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 Job, Server +from l4d2web.models import Job, Server, ServerLiveState, ServerPlayerSession, SteamUserProfile bp = Blueprint("server", __name__) @@ -189,3 +190,84 @@ def enqueue_server_operation(server_id: int, operation: str) -> Response: if operation == "delete": return redirect("/servers") return redirect(f"/servers/{server_id}") + + +@bp.get("/servers//live-state") +@require_login +def live_state_fragment(server_id: int) -> Response: + 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) + + stale_seconds = current_app.config.get("LIVE_STATE_STALE_SECONDS", 30) + cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(seconds=stale_seconds) + + latest = db.scalar( + select(ServerLiveState) + .where(ServerLiveState.server_id == server.id) + .order_by(ServerLiveState.started_at.desc()) + .limit(1) + ) + + current_rows = db.execute( + select(ServerPlayerSession, SteamUserProfile) + .outerjoin( + SteamUserProfile, + SteamUserProfile.steam_id_64 == ServerPlayerSession.steam_id_64, + ) + .where( + ServerPlayerSession.server_id == server.id, + ServerPlayerSession.left_at.is_(None), + ) + .order_by(ServerPlayerSession.joined_at) + ).all() + + current_ids = [r[0].steam_id_64 for r in current_rows] + + recent_cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta( + days=current_app.config.get("LIVE_STATE_HISTORY_DAYS", 30) + ) + + recent_rows = db.execute( + select( + ServerPlayerSession.steam_id_64, + func.max(ServerPlayerSession.left_at).label("last_seen"), + ServerPlayerSession.name_at_join, + SteamUserProfile.persona_name, + SteamUserProfile.avatar_url, + ) + .outerjoin( + SteamUserProfile, + SteamUserProfile.steam_id_64 == ServerPlayerSession.steam_id_64, + ) + .where( + ServerPlayerSession.server_id == server.id, + ServerPlayerSession.left_at.is_not(None), + ServerPlayerSession.left_at >= recent_cutoff, + ~ServerPlayerSession.steam_id_64.in_(current_ids) if current_ids else True, + ) + .group_by( + ServerPlayerSession.steam_id_64, + SteamUserProfile.persona_name, + SteamUserProfile.avatar_url, + ServerPlayerSession.name_at_join, + ) + .order_by(func.max(ServerPlayerSession.left_at).desc()) + .limit(20) + ).all() + + return render_template( + "_live_state.html", + server=server, + snapshot=latest, + snapshot_fresh=(latest is not None and latest.last_seen_at >= cutoff), + current_players=current_rows, + recent_players=recent_rows, + now=datetime.now(UTC).replace(tzinfo=None), + poll_seconds=current_app.config.get("LIVE_STATE_POLL_SECONDS", 5), + ) diff --git a/l4d2web/templates/_live_state.html b/l4d2web/templates/_live_state.html new file mode 100644 index 0000000..a2d0071 --- /dev/null +++ b/l4d2web/templates/_live_state.html @@ -0,0 +1,57 @@ +
+

Live state

+ {% if not snapshot or not snapshot_fresh %} +

No data — server is not currently reporting.

+ {% else %} +

+ {{ snapshot.players }}/{{ snapshot.max_players }} + {% if snapshot.hibernating %}· idle{% endif %} + · {{ snapshot.map }} + + polled {{ ((now - snapshot.last_seen_at).total_seconds() | int) }}s ago + +

+ {% endif %} + + {% if current_players %} +

Current players

+
    + {% for session, profile in current_players %} +
  • + {% if profile and profile.avatar_url %} + + {% else %} + + {% endif %} + {{ (profile and profile.persona_name) or session.name_at_join }} + + joined {{ ((now - session.joined_at).total_seconds() // 60) | int }}m ago + · ping {{ session.min_ping }}-{{ session.max_ping }}ms + +
  • + {% endfor %} +
+ {% endif %} + + {% if recent_players %} +

Recent players

+
    + {% for row in recent_players %} +
  • + {% if row.avatar_url %} + + {% else %} + + {% endif %} + {{ row.persona_name or row.name_at_join }} + + last seen {{ ((now - row.last_seen).total_seconds() // 60) | int }}m ago + +
  • + {% endfor %} +
+ {% endif %} +
diff --git a/l4d2web/templates/server_detail.html b/l4d2web/templates/server_detail.html index 3643ace..508f044 100644 --- a/l4d2web/templates/server_detail.html +++ b/l4d2web/templates/server_detail.html @@ -19,6 +19,12 @@

Server Log


 
+  
+
+

Files

{% if not file_tree_root_entries %}

No files yet — start the server to mount its runtime.

diff --git a/l4d2web/tests/test_servers.py b/l4d2web/tests/test_servers.py index 7742230..b8c0e4c 100644 --- a/l4d2web/tests/test_servers.py +++ b/l4d2web/tests/test_servers.py @@ -510,6 +510,64 @@ def test_servers_index_renders_live_state_badge(user_client_with_blueprints) -> assert "c1m1_hotel" not in html +def test_live_state_fragment_renders_current_and_recent(user_client_with_blueprints) -> None: + from datetime import timedelta + from sqlalchemy import select + from l4d2web.models import ( + Server, ServerLiveState, ServerPlayerSession, SteamUserProfile, + ) + + client, data = user_client_with_blueprints + now = datetime.now(UTC).replace(tzinfo=None) + with session_scope() as db: + srv = Server( + user_id=data["user_id"], blueprint_id=data["blueprint_id"], + name="srv", port=27800, rcon_password="x", actual_state="running", + ) + db.add(srv); db.flush() + srv_id = srv.id + db.add(ServerLiveState( + server_id=srv_id, started_at=now, last_seen_at=now, + players=1, max_players=4, bots=0, map="c1m2_streets", hibernating=False, + )) + db.add(ServerPlayerSession( + server_id=srv_id, steam_id_64="76561197960828710", + joined_at=now - timedelta(minutes=5), left_at=None, + name_at_join="Crone", min_ping=40, max_ping=60, + )) + db.add(ServerPlayerSession( + server_id=srv_id, steam_id_64="76561198021234567", + joined_at=now - timedelta(hours=2), left_at=now - timedelta(hours=1), + name_at_join="OldPlayer", min_ping=20, max_ping=80, + )) + db.add(SteamUserProfile( + steam_id_64="76561197960828710", + persona_name="MrCool42", + avatar_url="https://avatars.cloudflare.steamstatic.com/cur_medium.jpg", + fetched_at=now, + )) + db.add(SteamUserProfile( + steam_id_64="76561198021234567", + persona_name="OldPersona", + avatar_url="https://avatars.cloudflare.steamstatic.com/old_medium.jpg", + fetched_at=now, + )) + + res = client.get(f"/servers/{srv_id}/live-state") + assert res.status_code == 200 + html = res.get_data(as_text=True) + # Summary + assert "1/4" in html + assert "c1m2_streets" in html + # Current player block + assert "MrCool42" in html + assert "cur_medium.jpg" in html + assert "40-60" in html or "40–60" in html + # Recent block — only OldPlayer, not MrCool42 + assert "OldPersona" in html + assert "old_medium.jpg" in html + + def test_reset_operation_enqueues_job_and_stops_desired_state(user_client_with_blueprints) -> None: from sqlalchemy import select