feat(servers): show live counts + map badge in server list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-12 22:14:57 +02:00
parent 0dc61d5de4
commit 072d9f78e7
No known key found for this signature in database
3 changed files with 116 additions and 3 deletions

View file

@ -1,6 +1,7 @@
import json 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 sqlalchemy import func, select, update
from l4d2web.auth import current_user, require_admin, require_login from l4d2web.auth import current_user, require_admin, require_login
@ -12,6 +13,7 @@ from l4d2web.models import (
Overlay, Overlay,
OverlayWorkshopItem, OverlayWorkshopItem,
Server, Server,
ServerLiveState,
User, User,
WorkshopItem, WorkshopItem,
) )
@ -154,6 +156,42 @@ def servers_page() -> str:
select(BlueprintModel).where(BlueprintModel.user_id == user.id).order_by(BlueprintModel.name) select(BlueprintModel).where(BlueprintModel.user_id == user.id).order_by(BlueprintModel.name)
).all() ).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 prefill_blueprint_id: int | None = None
raw_prefill = request.args.get("blueprint_id") raw_prefill = request.args.get("blueprint_id")
if raw_prefill: if raw_prefill:
@ -169,6 +207,7 @@ def servers_page() -> str:
rows=rows, rows=rows,
blueprints=blueprints, blueprints=blueprints,
prefill_blueprint_id=prefill_blueprint_id, prefill_blueprint_id=prefill_blueprint_id,
live_state_by_server=live_state_by_server,
) )

View file

@ -13,18 +13,30 @@
{% endif %} {% endif %}
</div> </div>
<table class="table"> <table class="table">
<thead><tr><th>Name</th><th>Port</th><th>Blueprint</th><th>Desired</th><th>Actual</th></tr></thead> <thead><tr><th>Name</th><th>Port</th><th>Blueprint</th><th>Desired</th><th>Actual</th><th>Live</th></tr></thead>
<tbody> <tbody>
{% for server, blueprint in rows %} {% for server, blueprint in rows %}
{% set ls = live_state_by_server.get(server.id) %}
<tr> <tr>
<td><a href="/servers/{{ server.id }}">{{ server.name }}</a></td> <td><a href="/servers/{{ server.id }}">{{ server.name }}</a></td>
<td>{{ server.port }}</td> <td>{{ server.port }}</td>
<td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td> <td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td>
<td>{{ server.desired_state }}</td> <td>{{ server.desired_state }}</td>
<td>{{ server.actual_state }}</td> <td>{{ server.actual_state }}</td>
<td class="server-live">
{% if server.actual_state != 'running' %}
<span class="muted"></span>
{% elif ls is none or not ls.fresh %}
<span class="muted" title="no recent data">?</span>
{% elif ls.hibernating %}
{{ ls.players }}/{{ ls.max_players }} · idle · {{ ls.map }}
{% else %}
{{ ls.players }}/{{ ls.max_players }} · {{ ls.map }}
{% endif %}
</td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="5" class="muted">No servers configured.</td></tr> <tr><td colspan="6" class="muted">No servers configured.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View file

@ -446,6 +446,68 @@ def test_create_server_generates_rcon_password(user_client_with_blueprints) -> N
assert len(row.rcon_password) >= 32 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: def test_reset_operation_enqueues_job_and_stops_desired_state(user_client_with_blueprints) -> None:
from sqlalchemy import select from sqlalchemy import select