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
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue