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:
@@ -6,6 +6,28 @@ import schemaSql from './schema.sql?raw';
|
||||
|
||||
let _db: Database.Database | null = null;
|
||||
|
||||
// Columns that were added to `servers` after v0.1 shipped. Applied on every
|
||||
// boot; better-sqlite3 has no `ADD COLUMN IF NOT EXISTS`, so we run each and
|
||||
// swallow the "duplicate column" error.
|
||||
const SERVERS_MIGRATIONS: Array<{ column: string; ddl: string }> = [
|
||||
{ column: 'last_map', ddl: 'ALTER TABLE servers ADD COLUMN last_map TEXT' },
|
||||
{ column: 'last_gametype', ddl: 'ALTER TABLE servers ADD COLUMN last_gametype TEXT' },
|
||||
{ column: 'last_roster', ddl: 'ALTER TABLE servers ADD COLUMN last_roster TEXT' },
|
||||
{ column: 'last_seen_at', ddl: 'ALTER TABLE servers ADD COLUMN last_seen_at TEXT' },
|
||||
{ column: 'last_error', ddl: 'ALTER TABLE servers ADD COLUMN last_error TEXT' }
|
||||
];
|
||||
|
||||
function migrateServers(handle: Database.Database): void {
|
||||
for (const m of SERVERS_MIGRATIONS) {
|
||||
try {
|
||||
handle.exec(m.ddl);
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message ?? '';
|
||||
if (!/duplicate column name/i.test(msg)) throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function db(): Database.Database {
|
||||
if (_db) return _db;
|
||||
|
||||
@@ -14,6 +36,7 @@ export function db(): Database.Database {
|
||||
_db.pragma('journal_mode = WAL');
|
||||
_db.pragma('foreign_keys = ON');
|
||||
_db.exec(schemaSql);
|
||||
migrateServers(_db);
|
||||
|
||||
return _db;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ CREATE TABLE IF NOT EXISTS servers (
|
||||
rcon_host TEXT, -- optional per-server override of RCON_HOST
|
||||
last_players INTEGER,
|
||||
last_checked_at TEXT,
|
||||
last_map TEXT, -- from RCON getstatus mapname
|
||||
last_gametype TEXT, -- from RCON getstatus g_gametype
|
||||
last_roster TEXT, -- JSON array of RconPlayer
|
||||
last_seen_at TEXT, -- ISO ts of last successful RCON reply
|
||||
last_error TEXT, -- last RCON failure reason (cleared on success)
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export type Health = 'online' | 'stale' | 'offline';
|
||||
|
||||
const ONLINE_MAX_MS = 30_000;
|
||||
const STALE_MAX_MS = 120_000;
|
||||
|
||||
/**
|
||||
* Classify server health from its last successful RCON timestamp.
|
||||
* `offline` if we've never seen it, or if RCON just failed, or if the last
|
||||
* successful reply is >120s old. `stale` if the last reply is 30-120s old
|
||||
* (RCON glitchy). `online` if fresh.
|
||||
*/
|
||||
export function classifyHealth(lastSeenAt: string | null, lastError: string | null): Health {
|
||||
if (!lastSeenAt) return 'offline';
|
||||
const age = Date.now() - new Date(lastSeenAt).getTime();
|
||||
if (!Number.isFinite(age) || age < 0) return 'offline';
|
||||
if (age <= ONLINE_MAX_MS) {
|
||||
// Even with a fresh last_seen, a very recent error demotes to stale
|
||||
return lastError ? 'stale' : 'online';
|
||||
}
|
||||
if (age <= STALE_MAX_MS) return 'stale';
|
||||
return 'offline';
|
||||
}
|
||||
@@ -71,6 +71,11 @@ export interface ServerRow {
|
||||
rcon_host: string | null;
|
||||
last_players: number | null;
|
||||
last_checked_at: string | null;
|
||||
last_map: string | null;
|
||||
last_gametype: string | null;
|
||||
last_roster: string | null; // JSON array of RconPlayer, or null
|
||||
last_seen_at: string | null; // last successful RCON reply
|
||||
last_error: string | null; // cleared on success
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -116,7 +121,12 @@ export function setServerField(
|
||||
'game_port',
|
||||
'rcon_host',
|
||||
'last_players',
|
||||
'last_checked_at'
|
||||
'last_checked_at',
|
||||
'last_map',
|
||||
'last_gametype',
|
||||
'last_roster',
|
||||
'last_seen_at',
|
||||
'last_error'
|
||||
];
|
||||
if (!allowed.includes(field)) throw new Error(`refusing to set ${field}`);
|
||||
db().prepare(`UPDATE servers SET ${field} = ? WHERE name = ?`).run(value, name);
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user