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
|
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),
|
||||||
|
)
|
||||||
|
|
|
||||||
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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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 "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:
|
def test_reset_operation_enqueues_job_and_stops_desired_state(user_client_with_blueprints) -> None:
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue