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
+14 -1
View File
@@ -1,9 +1,22 @@
import { EventEmitter } from 'node:events';
import type { RconPlayer } from '../integrations/rcon.js';
import type { Health } from '../fleet/health.js';
// One in-process event bus, multiplexed by the SSE endpoints. Kept separate
// from any specific subscriber so push/beta/sweep can emit without pulling
// the whole worker module in.
export interface ServerStatusEntry {
server: string;
players: number | null;
map: string | null;
gametype: string | null;
roster: RconPlayer[] | null;
health: Health;
lastSeenAt: string | null;
lastError: string | null;
}
export type PipelineEvent =
| { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] }
| { type: 'server-changed'; deploymentId: string; server: string; files: number }
@@ -16,7 +29,7 @@ export type PipelineEvent =
error: string | null;
}
| { type: 'sweep-tick'; frozen: boolean; reason?: string }
| { type: 'server-status'; entries: Array<{ server: string; players: number | null; name?: string }> };
| { type: 'server-status'; entries: ServerStatusEntry[] };
class Bus extends EventEmitter {
constructor() {
+50 -7
View File
@@ -1,7 +1,9 @@
import { sweep } from './sweep.js';
import { emit } from './bus.js';
import type { ServerStatusEntry } from './bus.js';
import { rconGetStatus } from '../integrations/rcon.js';
import { listServers, seedServerRegistry } from '../fleet/servers.js';
import { listServers, seedServerRegistry, setServerField } from '../fleet/servers.js';
import { classifyHealth } from '../fleet/health.js';
const TICK_MS = 15_000;
const STATUS_REFRESH_MS = 30_000;
@@ -25,7 +27,7 @@ async function fanoutStatus(): Promise<void> {
const servers = listServers();
if (servers.length === 0) return;
const entries: Array<{ server: string; players: number | null; name?: string }> = new Array(servers.length);
const entries: ServerStatusEntry[] = new Array(servers.length);
let cursor = 0;
async function pump(): Promise<void> {
@@ -34,11 +36,44 @@ async function fanoutStatus(): Promise<void> {
if (i >= servers.length) return;
const srv = servers[i];
const status = await rconGetStatus(srv.name).catch(() => null);
entries[i] = {
server: srv.name,
players: status?.players ?? null,
name: status?.map ?? undefined
};
const now = new Date().toISOString();
if (status) {
// Persist last-observed state. last_seen_at is the health anchor.
setServerField(srv.name, 'last_players', status.players);
setServerField(srv.name, 'last_map', status.map);
setServerField(srv.name, 'last_gametype', status.gametype);
setServerField(srv.name, 'last_roster', JSON.stringify(status.roster));
setServerField(srv.name, 'last_seen_at', now);
setServerField(srv.name, 'last_checked_at', now);
setServerField(srv.name, 'last_error', null);
entries[i] = {
server: srv.name,
players: status.players,
map: status.map,
gametype: status.gametype,
roster: status.roster,
health: classifyHealth(now, null),
lastSeenAt: now,
lastError: null
};
} else {
// RCON unreachable — leave last_seen_at alone (health decays
// to offline naturally), record the failure reason.
const error = 'rcon unreachable';
setServerField(srv.name, 'last_checked_at', now);
setServerField(srv.name, 'last_error', error);
entries[i] = {
server: srv.name,
players: srv.last_players,
map: srv.last_map,
gametype: srv.last_gametype,
roster: srv.last_roster ? safeJson(srv.last_roster) : null,
health: classifyHealth(srv.last_seen_at, error),
lastSeenAt: srv.last_seen_at,
lastError: error
};
}
}
}
await Promise.all(Array.from({ length: STATUS_CONCURRENCY }, pump));
@@ -46,6 +81,14 @@ async function fanoutStatus(): Promise<void> {
emit({ type: 'server-status', entries });
}
function safeJson<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
export function startWorker(): void {
if (started) return;
started = true;