Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions backend/server/api/census.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@ async def get_census_health() -> dict:
return census_health.get_state()


@router.get("/census/server-status")
async def get_server_status() -> dict:
"""The Census-reported game-server state for the world in focus (footer
indicator). ``state`` is Daybreak's raw token — low/medium/high mean up,
plus locked and down; ``unknown`` until the first fetch lands."""
from backend.server.server_context import current_world

world = current_world()
info = census_health.get_server_state(world)
return {
"world": world,
"state": (info or {}).get("state", "unknown"),
"reported_at": (info or {}).get("reported_at", 0),
}


@router.get("/census/stream")
async def census_stream(request: Request) -> StreamingResponse:
async def gen():
Expand Down
55 changes: 55 additions & 0 deletions backend/server/census_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,71 @@
# Probe a real collection (not the base `/eq2/` index) so the response is a
# normal Census JSON document we can validate. ``c:limit=1`` keeps it tiny.
_PROBE_URL = f"{_CENSUS_BASE_URL}/s:{_SERVICE_ID}/json/get/eq2/world?c:limit=1"
# Daybreak's cross-game server list; filtered to EQ2 rows client-side.
_SERVER_STATUS_URL = f"{_CENSUS_BASE_URL}/s:{_SERVICE_ID}/get/global/game_server_status?c:limit=1000"

_status: str = "unknown" # "up" | "down" | "unknown"
_checked_at: int = 0
# {world_name: {"state": last_reported_state, "reported_at": unix}} for every
# EQ2 server in the game_server_status feed. Refreshed by the same 5-minute
# loop as the health probe.
_server_states: dict[str, dict] = {}


def _reset_for_test() -> None:
global _status, _checked_at
_status, _checked_at = "unknown", 0
_server_states.clear()


def get_state() -> dict:
return {"status": _status, "checked_at": _checked_at}


def get_server_state(world: str) -> dict | None:
"""The Census-reported state for one EQ2 server (e.g. "high", "locked",
"down"), or None when the feed hasn't been fetched or lacks the world."""
return _server_states.get(world.lower())


def _parse_server_states(body: dict) -> dict[str, dict]:
"""game_server_status envelope → {world_lower: {name, state, reported_at}}."""
out: dict[str, dict] = {}
for row in body.get("game_server_status_list", []) or []:
if not isinstance(row, dict) or row.get("game_code") != "eq2":
continue
name = str(row.get("name") or "")
if not name:
continue
try:
reported_at = int(row.get("last_reported_time") or 0)
except (TypeError, ValueError):
reported_at = 0
out[name.lower()] = {
"name": name,
"state": str(row.get("last_reported_state") or "unknown").lower(),
"reported_at": reported_at,
}
return out


async def _fetch_server_states() -> None:
"""Refresh the per-server state map. Failures leave the previous map in
place (stale beats empty for a footer indicator)."""
global _server_states
timeout = aiohttp.ClientTimeout(total=8)
try:
async with aiohttp.ClientSession(timeout=timeout) as s, s.get(_SERVER_STATUS_URL) as r:
if r.status != 200:
return
body = json.loads(await r.text())
parsed = _parse_server_states(body)
if parsed:
_server_states = parsed
except Exception as exc:
_log.info("[census-health] server-status fetch failed: %s", exc)


def is_down() -> bool:
return _status == "down"

Expand Down Expand Up @@ -89,6 +140,10 @@ async def refresh_health() -> str:
event on change (import is local to avoid a cycle)."""
global _status, _checked_at
ok = await _probe_census()
if ok:
# Same cadence as the probe; skipped while Census is down (the feed
# lives on the same host, and stale-but-labelled beats churn).
await _fetch_server_states()
new = "up" if ok else "down"
changed = new != _status
_status, _checked_at = new, int(time.time())
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { Link } from 'react-router-dom'
import logo from './assets/EQ2L.webp'
import ServerLaunchTimer from './components/ServerLaunchTimer'
import CensusStatus from './components/CensusStatus'
import ServerStatus from './components/ServerStatus'
import { MobileNav } from './components/MobileNav'

function LoginGate() {
Expand Down Expand Up @@ -269,6 +270,7 @@ function Layout() {
Support the site
</Link>
</span>
<ServerStatus />
<CensusStatus />
</footer>
</div>
Expand Down
92 changes: 92 additions & 0 deletions frontend/src/components/ServerStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react'

import { useServer } from '../hooks/useServer'

/**
* Footer indicator for the in-focus EQ2 server's Census-reported state,
* shown next to the Census API indicator. Green for any "up" population
* state (low/medium/high), orange for locked, red for down; grey until
* the first report lands. The tooltip carries the raw reported status.
*/

interface ServerStatusData {
world: string
state: string
reported_at: number
}

/** Daybreak state token → dot colour + label suffix + tooltip description.
* The low/medium/high tokens are POPULATION levels on an up server, not
* degraded states. Exported for tests. */
export function presentState(state: string): { colour: string; glow: string; suffix: string; describe: string } {

Check warning on line 21 in frontend/src/components/ServerStatus.tsx

View workflow job for this annotation

GitHub Actions / Frontend (typecheck + build)

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const s = state.toLowerCase()
if (['low', 'medium', 'high'].includes(s)) {
return {
colour: 'var(--color-success)',
glow: '0 0 5px rgba(74,222,128,0.7)',
suffix: 'online',
describe: `online — current population: ${s}`,
}
}
if (s === 'up') {
return { colour: 'var(--color-success)', glow: '0 0 5px rgba(74,222,128,0.7)', suffix: 'online', describe: 'online' }
}
if (s === 'locked') {
return {
colour: 'var(--color-warning)',
glow: '0 0 5px rgba(251,191,36,0.7)',
suffix: 'locked',
describe: 'locked — not accepting logins',
}
}
if (['down', 'offline'].includes(s)) {
return { colour: 'var(--color-danger)', glow: '0 0 5px rgba(248,113,113,0.7)', suffix: 'down', describe: 'down' }
}
return { colour: 'var(--color-text-muted)', glow: 'none', suffix: 'status unknown', describe: 'no status reported yet' }
}

const REFRESH_MS = 5 * 60 * 1000 // match the backend poll cadence

export default function ServerStatus() {
const server = useServer()
const displayName = server?.displayName
const [data, setData] = useState<ServerStatusData | null>(null)

useEffect(() => {
let cancelled = false
const load = () =>
fetch('/api/census/server-status', { credentials: 'include' })
.then(r => (r.ok ? r.json() : null))
.then(d => {
if (!cancelled && d) setData(d as ServerStatusData)
})
.catch(() => {})
load()
const t = setInterval(load, REFRESH_MS)
return () => {
cancelled = true
clearInterval(t)
}
}, [])

const state = data?.state ?? 'unknown'
const { colour, glow, suffix, describe } = presentState(state)
const name = displayName || data?.world || 'Server'
const reported = data?.reported_at
? ` (reported ${new Date(data.reported_at * 1000).toLocaleTimeString()})`
: ''

return (
<span
className="inline-flex items-center gap-1.5"
title={`${name} server: ${describe}${reported}`}
>
<span
className="inline-block rounded-full shrink-0"
style={{ width: '8px', height: '8px', background: colour, boxShadow: glow }}
aria-hidden="true"
/>
<span>{name} server {suffix}</span>
</span>
)
}
2 changes: 1 addition & 1 deletion frontend/src/pages/characterSheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export const STAT_GROUPS: StatGroupDef[] = [
{ key: 'crit_bonus', label: 'Crit Bonus', fmt: 'pct1' },
{ key: 'fervor', label: 'Fervor', fmt: 'dec1' },
{ key: 'dps', label: 'DPS', fmt: 'dec1' },
{ key: 'double_attack', label: 'Double Attack', fmt: 'pct1' },
{ key: 'double_attack', label: 'Multi Attack', fmt: 'pct1' },
{ key: 'ability_doublecast', label: 'Ability Doublecast', fmt: 'pct1' },
{ key: 'attack_speed', label: 'Attack Speed', fmt: 'pct1' },
{ key: 'ability_mod', label: 'Ability Mod', fmt: 'int' },
Expand Down
63 changes: 63 additions & 0 deletions tests/server/test_census_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,66 @@ def test_body_looks_healthy_rejects_non_dict():
assert ch._body_looks_healthy([]) is False # type: ignore[arg-type]
assert ch._body_looks_healthy("oops") is False # type: ignore[arg-type]
assert ch._body_looks_healthy(None) is False # type: ignore[arg-type]


# ---------------------------------------------------------------------------
# game_server_status parsing + endpoint (footer server-status indicator)
# ---------------------------------------------------------------------------

import pytest
from httpx import ASGITransport, AsyncClient

from backend.server import census_health


def test_parse_server_states_filters_to_eq2_and_normalises():
body = {
"game_server_status_list": [
{"name": "Wuoshi", "game_code": "eq2", "last_reported_state": "HIGH", "last_reported_time": "1784023424"},
{"name": "Antonia Bayle", "game_code": "eq2", "last_reported_state": "locked", "last_reported_time": "x"},
{"name": "Povar", "game_code": "eq", "last_reported_state": "up", "last_reported_time": "1"},
{"name": "", "game_code": "eq2", "last_reported_state": "up"},
"garbage",
]
}
out = census_health._parse_server_states(body)
assert out["wuoshi"] == {"name": "Wuoshi", "state": "high", "reported_at": 1784023424}
assert out["antonia bayle"]["state"] == "locked"
assert out["antonia bayle"]["reported_at"] == 0 # unparseable time -> 0
assert "povar" not in out # wrong game
assert len(out) == 2


def test_get_server_state_case_insensitive(monkeypatch):
monkeypatch.setattr(
census_health, "_server_states", {"wuoshi": {"name": "Wuoshi", "state": "high", "reported_at": 1}}
)
assert census_health.get_server_state("Wuoshi")["state"] == "high"
assert census_health.get_server_state("nope") is None


@pytest.mark.asyncio
async def test_server_status_endpoint_unknown_before_first_fetch(app, monkeypatch):
monkeypatch.setattr(census_health, "_server_states", {})
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
r = await c.get("/api/census/server-status")
assert r.status_code == 200
assert r.json()["state"] == "unknown"


@pytest.mark.asyncio
async def test_server_status_endpoint_returns_current_world(app, monkeypatch):
monkeypatch.setattr(
census_health,
"_server_states",
{
"wuoshi": {"name": "Wuoshi", "state": "locked", "reported_at": 42},
"varsoon": {"name": "Varsoon", "state": "high", "reported_at": 43},
},
)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
r = await c.get("/api/census/server-status")
body = r.json()
assert body["state"] in ("locked", "high") # whichever world the test app focuses
assert body["reported_at"] in (42, 43)
assert body["world"].lower() in ("wuoshi", "varsoon")
Loading