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:
@@ -1,5 +1,5 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { RconPlayer } from '../integrations/rcon.js';
|
||||
import type { RconEndpoint, RconFailure, RconPlayer } from '../integrations/rcon.js';
|
||||
import type { Health } from '../fleet/health.js';
|
||||
|
||||
// One in-process event bus, multiplexed by the SSE endpoints. Kept separate
|
||||
@@ -15,6 +15,8 @@ export interface ServerStatusEntry {
|
||||
health: Health;
|
||||
lastSeenAt: string | null;
|
||||
lastError: string | null;
|
||||
endpoint: RconEndpoint | null;
|
||||
reason: RconFailure | null;
|
||||
}
|
||||
|
||||
export type PipelineEvent =
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user