Surface structured RCON failure reasons in the fleet UI

rconGetStatus previously collapsed at least five distinct failure modes
(no password, no port, socket error, timeout, bad reply) into a single
null return, so the UI could only say "rcon unreachable" — useless when
diagnosing a real Alcatraz outage.

Refactored to rconStatusResult() returning a discriminated union with a
RconFailure reason and human-facing detail. The worker persists these as
"<reason>:<detail>" in last_error (no schema change). /api/servers now
returns the derived endpoint (host:port) per server plus a top-level
integrations block (rcon/komodo configured, timeout, default host).

Server detail page gains an RCON diagnostic panel when health is not
online: shows the exact endpoint being dialed, integration status,
timeout, a color-coded reason tag, the detail, and a targeted fix hint.
Wrong-password replies are detected via "bad rcon" text in the print
body.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 09:06:27 -04:00
co-authored by Claude Opus 4.7
parent 9bff47a9a4
commit 7b4e6e0565
9 changed files with 372 additions and 72 deletions
+36 -17
View File
@@ -1,7 +1,8 @@
import { sweep } from './sweep.js';
import { emit } from './bus.js';
import type { ServerStatusEntry } from './bus.js';
import { rconGetStatus } from '../integrations/rcon.js';
import { rconStatusResult } from '../integrations/rcon.js';
import type { RconFailure } from '../integrations/rcon.js';
import { listServers, seedServerRegistry, setServerField } from '../fleet/servers.js';
import { classifyHealth } from '../fleet/health.js';
@@ -23,6 +24,15 @@ async function tick(): Promise<void> {
}
}
/**
* Encode a structured RCON failure as `"reason:detail"` so `last_error`
* stays a single column but the UI can split it apart when rendering the
* diagnostic panel. Reasons are stable; details are human-facing.
*/
function encodeError(reason: RconFailure, detail: string): string {
return `${reason}:${detail}`;
}
async function fanoutStatus(): Promise<void> {
const servers = listServers();
if (servers.length === 0) return;
@@ -35,43 +45,52 @@ async function fanoutStatus(): Promise<void> {
const i = cursor++;
if (i >= servers.length) return;
const srv = servers[i];
const status = await rconGetStatus(srv.name).catch(() => null);
const result = await rconStatusResult(srv.name).catch((err) => ({
ok: false as const,
endpoint: null,
reason: 'send-error' as RconFailure,
detail: (err as Error).message
}));
const now = new Date().toISOString();
if (status) {
if (result.ok) {
// 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_players', result.status.players);
setServerField(srv.name, 'last_map', result.status.map);
setServerField(srv.name, 'last_gametype', result.status.gametype);
setServerField(srv.name, 'last_roster', JSON.stringify(result.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,
players: result.status.players,
map: result.status.map,
gametype: result.status.gametype,
roster: result.status.roster,
health: classifyHealth(now, null),
lastSeenAt: now,
lastError: null
lastError: null,
endpoint: result.endpoint,
reason: null
};
} else {
// RCON unreachable — leave last_seen_at alone (health decays
// to offline naturally), record the failure reason.
const error = 'rcon unreachable';
// to offline naturally), record the structured failure.
const encoded = encodeError(result.reason, result.detail);
setServerField(srv.name, 'last_checked_at', now);
setServerField(srv.name, 'last_error', error);
setServerField(srv.name, 'last_error', encoded);
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),
health: classifyHealth(srv.last_seen_at, encoded),
lastSeenAt: srv.last_seen_at,
lastError: error
lastError: encoded,
endpoint: result.endpoint,
reason: result.reason
};
}
}