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:
parent
b00a3cceea
commit
9aaa26d9a9
4 changed files with 206 additions and 3 deletions
|
|
@ -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/<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),
|
||||
)
|
||||
|
|
|
|||
57
l4d2web/templates/_live_state.html
Normal file
57
l4d2web/templates/_live_state.html
Normal 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>
|
||||
|
|
@ -19,6 +19,12 @@
|
|||
<h2 class="section-title">Server Log</h2>
|
||||
<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>
|
||||
{% if not file_tree_root_entries %}
|
||||
<p class="muted">No files yet — start the server to mount its runtime.</p>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue