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:
parent
0dc61d5de4
commit
072d9f78e7
3 changed files with 116 additions and 3 deletions
|
|
@ -1,6 +1,7 @@
|
|||
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 l4d2web.auth import current_user, require_admin, require_login
|
||||
|
|
@ -12,6 +13,7 @@ from l4d2web.models import (
|
|||
Overlay,
|
||||
OverlayWorkshopItem,
|
||||
Server,
|
||||
ServerLiveState,
|
||||
User,
|
||||
WorkshopItem,
|
||||
)
|
||||
|
|
@ -154,6 +156,42 @@ def servers_page() -> str:
|
|||
select(BlueprintModel).where(BlueprintModel.user_id == user.id).order_by(BlueprintModel.name)
|
||||
).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
|
||||
raw_prefill = request.args.get("blueprint_id")
|
||||
if raw_prefill:
|
||||
|
|
@ -169,6 +207,7 @@ def servers_page() -> str:
|
|||
rows=rows,
|
||||
blueprints=blueprints,
|
||||
prefill_blueprint_id=prefill_blueprint_id,
|
||||
live_state_by_server=live_state_by_server,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,18 +13,30 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
<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>
|
||||
{% for server, blueprint in rows %}
|
||||
{% set ls = live_state_by_server.get(server.id) %}
|
||||
<tr>
|
||||
<td><a href="/servers/{{ server.id }}">{{ server.name }}</a></td>
|
||||
<td>{{ server.port }}</td>
|
||||
<td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td>
|
||||
<td>{{ server.desired_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>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="muted">No servers configured.</td></tr>
|
||||
<tr><td colspan="6" class="muted">No servers configured.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -446,6 +446,68 @@ def test_create_server_generates_rcon_password(user_client_with_blueprints) -> N
|
|||
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:
|
||||
from sqlalchemy import select
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue