feat(servers): add live-state panel with current and recent players

HTMX-refreshed /servers/<id>/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 <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-12 22:20:01 +02:00
parent b00a3cceea
commit 9aaa26d9a9
No known key found for this signature in database
4 changed files with 206 additions and 3 deletions

View file

@ -1,13 +1,14 @@
import secrets import secrets
from datetime import UTC, datetime, timedelta
from flask import Blueprint, Response, current_app, jsonify, redirect, request from flask import Blueprint, Response, current_app, jsonify, redirect, render_template, request
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
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 Job, Server from l4d2web.models import Job, Server, ServerLiveState, ServerPlayerSession, SteamUserProfile
bp = Blueprint("server", __name__) bp = Blueprint("server", __name__)
@ -189,3 +190,84 @@ def enqueue_server_operation(server_id: int, operation: str) -> Response:
if operation == "delete": if operation == "delete":
return redirect("/servers") return redirect("/servers")
return redirect(f"/servers/{server_id}") return redirect(f"/servers/{server_id}")
@bp.get("/servers/<int:server_id>/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),
)

View file

@ -0,0 +1,57 @@
<section class="panel live-state"
hx-get="/servers/{{ server.id }}/live-state"
hx-trigger="every {{ poll_seconds }}s"
hx-swap="outerHTML">
<h2 class="section-title">Live state</h2>
{% if not snapshot or not snapshot_fresh %}
<p class="muted">No data — server is not currently reporting.</p>
{% else %}
<p class="server-live-summary">
{{ snapshot.players }}/{{ snapshot.max_players }}
{% if snapshot.hibernating %}· idle{% endif %}
· {{ snapshot.map }}
<small class="muted">
polled {{ ((now - snapshot.last_seen_at).total_seconds() | int) }}s ago
</small>
</p>
{% endif %}
{% if current_players %}
<h3 class="section-subtitle">Current players</h3>
<ul class="player-grid">
{% for session, profile in current_players %}
<li class="player-card">
{% if profile and profile.avatar_url %}
<img class="avatar" src="{{ profile.avatar_url }}" alt="" loading="lazy">
{% else %}
<span class="avatar placeholder" aria-hidden="true"></span>
{% endif %}
<span class="name">{{ (profile and profile.persona_name) or session.name_at_join }}</span>
<span class="meta">
joined {{ ((now - session.joined_at).total_seconds() // 60) | int }}m ago
· ping {{ session.min_ping }}-{{ session.max_ping }}ms
</span>
</li>
{% endfor %}
</ul>
{% endif %}
{% if recent_players %}
<h3 class="section-subtitle">Recent players</h3>
<ul class="player-grid recent">
{% for row in recent_players %}
<li class="player-card">
{% if row.avatar_url %}
<img class="avatar" src="{{ row.avatar_url }}" alt="" loading="lazy">
{% else %}
<span class="avatar placeholder" aria-hidden="true"></span>
{% endif %}
<span class="name">{{ row.persona_name or row.name_at_join }}</span>
<span class="meta">
last seen {{ ((now - row.last_seen).total_seconds() // 60) | int }}m ago
</span>
</li>
{% endfor %}
</ul>
{% endif %}
</section>

View file

@ -19,6 +19,12 @@
<h2 class="section-title">Server Log</h2> <h2 class="section-title">Server Log</h2>
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre> <pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
<section class="panel live-state"
hx-get="/servers/{{ server.id }}/live-state"
hx-trigger="load, every 5s"
hx-swap="outerHTML">
</section>
<h2 class="section-title">Files</h2> <h2 class="section-title">Files</h2>
{% if not file_tree_root_entries %} {% if not file_tree_root_entries %}
<p class="muted">No files yet — start the server to mount its runtime.</p> <p class="muted">No files yet — start the server to mount its runtime.</p>

View file

@ -510,6 +510,64 @@ def test_servers_index_renders_live_state_badge(user_client_with_blueprints) ->
assert "c1m1_hotel" not in html 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 "4060" 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: def test_reset_operation_enqueues_job_and_stops_desired_state(user_client_with_blueprints) -> None:
from sqlalchemy import select from sqlalchemy import select