Perk-a-cola redesign + surface RCON map/gametype/roster/health

Themes the fleet manager around the Electric Cherry perk-a-cola from Mob of
the Dead — cherry-red art-deco label with electric cyan discharge. New
design tokens (Alfa Slab One + Inter + JetBrains Mono, self-hosted), full
redesign of layout/dashboard/cards/detail page, tokens pass on
deployments/betas/pipeline/beta/diff, cherry+lightning favicon.

Lights up server data that RCON was already fetching but the UI dropped.
parsePlayerRoster() extracts num/score/ping/guid/name from both status
dialects. Worker persists last_map/last_gametype/last_roster/last_seen_at/
last_error (new columns with idempotent boot-time ALTERs) and emits them
over SSE. Fleet cards now show health dot, map · gametype, top-3 roster
preview, and last-seen. Detail page adds a sortable full roster with
distinct empty-lobby vs offline states. New health classifier
(online/stale/offline) drives the visual state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 08:20:54 -04:00
co-authored by Claude Opus 4.7
parent 854f5532a9
commit 9bff47a9a4
29 changed files with 1636 additions and 356 deletions
+58 -1
View File
@@ -131,6 +131,61 @@ export function parseInfoLine(text: string): Record<string, string> {
return out;
}
export interface RconPlayer {
num: number | null; // client slot — print dialect only
score: number;
ping: number;
guid: string | null; // 32-hex — print dialect only
name: string;
}
/**
* Extract every player row from a `status` (print) or `getstatus`
* (statusResponse) response. Returns [] when the payload has none.
* Two dialects, same output shape:
*
* print: num score ping <32-hex-guid> <name-with-spaces>
* statusResponse: score ping "<name>"
*/
export function parsePlayerRoster(text: string): RconPlayer[] {
const lines = text.split(/\r?\n/);
const sep = lines.findIndex((l) => /^-+\s+-+/.test(l.trim()));
if (sep !== -1) {
const out: RconPlayer[] = [];
// Match: num score ping guid(32-hex) name-rest
const re = /^\s*(\d+)\s+(-?\d+)\s+(\d+)\s+([0-9a-fA-F]{16,})\s+(.+?)\s*$/;
for (let i = sep + 1; i < lines.length; i++) {
const m = re.exec(lines[i]);
if (!m) continue;
out.push({
num: Number(m[1]),
score: Number(m[2]),
ping: Number(m[3]),
guid: m[4],
name: m[5]
});
}
return out;
}
// statusResponse dialect: rows after the info line, format: score ping "name"
const info = lines.findIndex((l) => l.startsWith('\\'));
if (info === -1) return [];
const out: RconPlayer[] = [];
const re = /^\s*(-?\d+)\s+(\d+)\s+"(.*)"\s*$/;
for (let i = info + 1; i < lines.length; i++) {
const m = re.exec(lines[i]);
if (!m) continue;
out.push({
num: null,
score: Number(m[1]),
ping: Number(m[2]),
guid: null,
name: m[3]
});
}
return out;
}
export async function rconGetPlayerCount(server: string): Promise<number | null> {
const ep = await endpointFor(server);
if (!ep) return null;
@@ -145,6 +200,7 @@ export interface RconStatus {
players: number;
map: string | null;
gametype: string | null;
roster: RconPlayer[];
}
export async function rconGetStatus(server: string): Promise<RconStatus | null> {
@@ -162,6 +218,7 @@ export async function rconGetStatus(server: string): Promise<RconStatus | null>
return {
players,
map: info.mapname ?? info.sv_mapname ?? null,
gametype: info.g_gametype ?? null
gametype: info.g_gametype ?? null,
roster: parsePlayerRoster(text)
};
}