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
+110 -33
View File
@@ -1,6 +1,6 @@
import { createSocket } from 'node:dgram';
import type { Socket } from 'node:dgram';
import { config, rconConfigured } from '../config.js';
import { config, komodoConfigured, rconConfigured } from '../config.js';
import { getServer, setServerField } from '../fleet/servers.js';
import { komodoResolveGamePort } from './komodo.js';
@@ -16,18 +16,33 @@ import { komodoResolveGamePort } from './komodo.js';
const PREFIX = Buffer.from([0xff, 0xff, 0xff, 0xff]);
async function sendRcon(host: string, port: number, command: string): Promise<Buffer | null> {
if (!rconConfigured()) return null;
// Distinct RCON failure modes. Surfaced to the UI so the operator can tell
// "wrong password" from "wrong host" from "Plutonium isn't listening".
export type RconFailure = 'no-config' | 'no-port' | 'send-error' | 'timeout' | 'bad-reply' | 'no-players';
export interface RconEndpoint {
host: string;
port: number;
}
type SendResult =
| { ok: true; buf: Buffer }
| { ok: false; reason: 'no-config' | 'send-error' | 'timeout'; detail: string };
async function sendRcon(host: string, port: number, command: string): Promise<SendResult> {
if (!rconConfigured()) {
return { ok: false, reason: 'no-config', detail: 'RCON_PASSWORD is not set' };
}
const payload = Buffer.concat([
PREFIX,
Buffer.from(`rcon ${config.rcon.password} ${command}\n`, 'utf8')
]);
return new Promise<Buffer | null>((resolve) => {
return new Promise<SendResult>((resolve) => {
const sock: Socket = createSocket('udp4');
let settled = false;
const finish = (val: Buffer | null): void => {
const finish = (val: SendResult): void => {
if (settled) return;
settled = true;
try {
@@ -38,41 +53,52 @@ async function sendRcon(host: string, port: number, command: string): Promise<Bu
resolve(val);
};
const timer = setTimeout(() => finish(null), config.rcon.timeoutMs);
const timer = setTimeout(
() => finish({ ok: false, reason: 'timeout', detail: `no reply within ${config.rcon.timeoutMs}ms` }),
config.rcon.timeoutMs
);
sock.on('message', (msg) => {
clearTimeout(timer);
finish(msg);
finish({ ok: true, buf: msg });
});
sock.on('error', () => {
sock.on('error', (err) => {
clearTimeout(timer);
finish(null);
finish({ ok: false, reason: 'send-error', detail: err.message });
});
sock.send(payload, 0, payload.length, port, host, (err) => {
if (err) {
clearTimeout(timer);
finish(null);
finish({ ok: false, reason: 'send-error', detail: err.message });
}
});
});
}
interface EndpointResolution {
endpoint: RconEndpoint | null;
// Present when endpoint is null — tells the caller why we couldn't dial.
reason?: 'unknown-server' | 'no-port-no-komodo' | 'no-port-komodo-empty';
}
/**
* Resolve the RCON endpoint (host + port) for a server, filling
* `servers.game_port` from Komodo on first miss. Returns null when we
* still can't determine a port after asking Komodo.
* `servers.game_port` from Komodo on first miss.
*/
async function endpointFor(server: string): Promise<{ host: string; port: number } | null> {
async function endpointFor(server: string): Promise<EndpointResolution> {
const row = getServer(server);
if (!row) return null;
if (!row) return { endpoint: null, reason: 'unknown-server' };
let port = row.game_port;
if (!port) {
if (!komodoConfigured()) {
return { endpoint: null, reason: 'no-port-no-komodo' };
}
port = await komodoResolveGamePort(server);
if (port) setServerField(server, 'game_port', port);
}
if (!port) return null;
return { host: row.rcon_host ?? config.rcon.host, port };
if (!port) return { endpoint: null, reason: 'no-port-komodo-empty' };
return { endpoint: { host: row.rcon_host ?? config.rcon.host, port } };
}
function stripPrefix(buf: Buffer): string | null {
@@ -187,11 +213,11 @@ export function parsePlayerRoster(text: string): RconPlayer[] {
}
export async function rconGetPlayerCount(server: string): Promise<number | null> {
const ep = await endpointFor(server);
if (!ep) return null;
const buf = await sendRcon(ep.host, ep.port, 'status');
if (!buf) return null;
const text = stripPrefix(buf);
const res = await endpointFor(server);
if (!res.endpoint) return null;
const send = await sendRcon(res.endpoint.host, res.endpoint.port, 'status');
if (!send.ok) return null;
const text = stripPrefix(send.buf);
if (!text) return null;
return parsePlayerCount(text);
}
@@ -203,22 +229,73 @@ export interface RconStatus {
roster: RconPlayer[];
}
export async function rconGetStatus(server: string): Promise<RconStatus | null> {
const ep = await endpointFor(server);
if (!ep) return null;
export type RconStatusResult =
| { ok: true; endpoint: RconEndpoint; status: RconStatus }
| { ok: false; endpoint: RconEndpoint | null; reason: RconFailure; detail: string };
/**
* Full RCON status probe with structured diagnostics. Callers get either
* (ok, endpoint, status) or (ok:false, endpoint?, reason, detail). This is
* the shape the fleet UI consumes to explain *why* a server is unreachable
* rather than just "unreachable".
*/
export async function rconStatusResult(server: string): Promise<RconStatusResult> {
const res = await endpointFor(server);
if (!res.endpoint) {
if (res.reason === 'unknown-server') {
return { ok: false, endpoint: null, reason: 'no-port', detail: `unknown server: ${server}` };
}
if (res.reason === 'no-port-no-komodo') {
return {
ok: false,
endpoint: null,
reason: 'no-port',
detail: 'no game_port set and Komodo is not configured'
};
}
return {
ok: false,
endpoint: null,
reason: 'no-port',
detail: 'no game_port — Komodo did not return one for this server'
};
}
const ep = res.endpoint;
// `getstatus` is anonymous on most CoD builds but Plutonium requires the
// authenticated rcon route for reliable results, so we ask via rcon.
const buf = await sendRcon(ep.host, ep.port, 'getstatus');
if (!buf) return null;
const text = stripPrefix(buf);
if (!text) return null;
const send = await sendRcon(ep.host, ep.port, 'getstatus');
if (!send.ok) {
return { ok: false, endpoint: ep, reason: send.reason, detail: send.detail };
}
const text = stripPrefix(send.buf);
if (!text) {
return {
ok: false,
endpoint: ep,
reason: 'bad-reply',
detail: 'reply missing 0xff prefix — wrong port or non-Plutonium listener?'
};
}
const info = parseInfoLine(text);
const players = parsePlayerCount(text);
if (players === null) return null;
if (players === null) {
// Reply arrived but neither the print separator nor an info line
// showed up — usually a wrong-password `print` body ("Bad rcon…").
const detail = text.trim().startsWith('print') && /bad\s+rcon/i.test(text)
? 'wrong RCON password'
: `unparseable reply (${text.length} bytes)`;
return { ok: false, endpoint: ep, reason: 'bad-reply', detail };
}
return {
players,
map: info.mapname ?? info.sv_mapname ?? null,
gametype: info.g_gametype ?? null,
roster: parsePlayerRoster(text)
ok: true,
endpoint: ep,
status: {
players,
map: info.mapname ?? info.sv_mapname ?? null,
gametype: info.g_gametype ?? null,
roster: parsePlayerRoster(text)
}
};
}
+3 -1
View File
@@ -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 =
+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
};
}
}
+43
View File
@@ -0,0 +1,43 @@
import type { RconFailure } from '$ui/stores/servers.js';
export interface DecodedRconError {
reason: RconFailure;
detail: string;
}
const KNOWN: RconFailure[] = ['no-config', 'no-port', 'send-error', 'timeout', 'bad-reply', 'no-players'];
/**
* Decode the "<reason>:<detail>" string the worker persists into
* `servers.last_error`. Returns null for empty/legacy strings so the UI can
* fall back to a generic message.
*/
export function decodeRconError(raw: string | null | undefined): DecodedRconError | null {
if (!raw) return null;
const idx = raw.indexOf(':');
if (idx <= 0) return null;
const reason = raw.slice(0, idx);
if (!KNOWN.includes(reason as RconFailure)) return null;
return { reason: reason as RconFailure, detail: raw.slice(idx + 1).trim() };
}
/**
* Human-facing hint about how to fix each RCON failure. Kept short — the
* detail already carries the specifics (host, port, byte counts, etc.).
*/
export function rconFixHint(reason: RconFailure): string {
switch (reason) {
case 'no-config':
return 'Set RCON_PASSWORD in the container env and restart.';
case 'no-port':
return 'Set game_port on the server row, or configure Komodo so it can resolve the port.';
case 'send-error':
return 'DNS or route problem — check the RCON host is reachable from this container.';
case 'timeout':
return 'Plutonium is not replying on the resolved UDP port. Verify the server is running and the port matches.';
case 'bad-reply':
return 'Something is listening but not speaking Plutonium RCON. Check the port and password.';
case 'no-players':
return 'Reply parsed but no player table was found — likely a server-side dialect mismatch.';
}
}
+8
View File
@@ -10,6 +10,12 @@ export interface RosterPlayer {
}
export type Health = 'online' | 'stale' | 'offline';
export type RconFailure = 'no-config' | 'no-port' | 'send-error' | 'timeout' | 'bad-reply' | 'no-players';
export interface RconEndpoint {
host: string;
port: number;
}
export interface ServerStatus {
server: string;
@@ -20,6 +26,8 @@ export interface ServerStatus {
health: Health;
lastSeenAt: string | null;
lastError: string | null;
endpoint: RconEndpoint | null;
reason: RconFailure | null;
}
export const statusByServer = writable<Map<string, ServerStatus>>(new Map());
+1
View File
@@ -19,6 +19,7 @@
last_error: string | null;
roster: RosterPlayer[] | null;
health: Health;
endpoint: { host: string; port: number } | null;
held: HeldRow[];
}
+14 -1
View File
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import { listServers, heldByBeta } from '$lib/server/fleet/servers.js';
import { classifyHealth } from '$lib/server/fleet/health.js';
import { config, komodoConfigured, rconConfigured } from '$lib/server/config.js';
import type { RconPlayer } from '$lib/server/integrations/rcon.js';
function safeJson<T>(raw: string | null): T | null {
@@ -13,11 +14,23 @@ function safeJson<T>(raw: string | null): T | null {
}
export async function GET() {
const rconHost = config.rcon.host;
const servers = listServers().map((s) => ({
...s,
roster: safeJson<RconPlayer[]>(s.last_roster),
health: classifyHealth(s.last_seen_at, s.last_error),
// Derived endpoint — same logic as endpointFor() but without asking
// Komodo. If we don't have a port yet, endpoint is null.
endpoint: s.game_port ? { host: s.rcon_host ?? rconHost, port: s.game_port } : null,
held: [...heldByBeta(s.name).entries()].map(([target, slug]) => ({ target, slug }))
}));
return json({ servers });
return json({
servers,
integrations: {
rcon: rconConfigured(),
komodo: komodoConfigured(),
rconTimeoutMs: config.rcon.timeoutMs,
defaultRconHost: rconHost
}
});
}
+117 -20
View File
@@ -2,10 +2,11 @@
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { statusByServer } from '$ui/stores/servers.js';
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
import type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
import StatusDot from '$ui/components/StatusDot.svelte';
import PlayerRoster from '$ui/components/PlayerRoster.svelte';
import { gametypeLabel } from '$ui/gametype.js';
import { decodeRconError, rconFixHint } from '$ui/rconError.js';
interface Server {
name: string;
@@ -22,8 +23,15 @@
last_error: string | null;
roster: RosterPlayer[] | null;
health: Health;
endpoint: RconEndpoint | null;
held: Array<{ target: string; slug: string }>;
}
interface Integrations {
rcon: boolean;
komodo: boolean;
rconTimeoutMs: number;
defaultRconHost: string;
}
interface DeploymentRow {
id: string;
action: string;
@@ -33,14 +41,16 @@
}
let server = $state<Server | null>(null);
let integrations = $state<Integrations | null>(null);
let history = $state<DeploymentRow[]>([]);
let name = $derived($page.params.name ?? '');
onMount(async () => {
const [srvRes, depRes] = await Promise.all([fetch('/api/servers'), fetch('/api/deployments')]);
const srvBody = (await srvRes.json()) as { servers: Server[] };
const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations };
const depBody = (await depRes.json()) as { deployments: DeploymentRow[] };
server = srvBody.servers.find((s) => s.name === name) ?? null;
integrations = srvBody.integrations;
history = depBody.deployments.filter((d) => d.servers.some((s) => s.server === name));
});
@@ -52,6 +62,11 @@
let effectiveHealth = $derived<Health>(live?.health ?? server?.health ?? 'offline');
let effectiveSeen = $derived(live?.lastSeenAt ?? server?.last_seen_at ?? null);
let effectiveErr = $derived(live?.lastError ?? server?.last_error ?? null);
let effectiveEndpoint = $derived<RconEndpoint | null>(live?.endpoint ?? server?.endpoint ?? null);
let liveReason = $derived<RconFailure | null>(live?.reason ?? null);
let decoded = $derived(decodeRconError(effectiveErr));
let effectiveReason = $derived<RconFailure | null>(liveReason ?? decoded?.reason ?? null);
let errorDetail = $derived(decoded?.detail ?? effectiveErr ?? '');
function ago(iso: string | null): string {
if (!iso) return 'never';
@@ -61,6 +76,11 @@
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
return `${Math.floor(d / 86_400_000)}d ago`;
}
function endpointLabel(ep: RconEndpoint | null): string {
if (!ep) return 'unresolved';
return `${ep.host}:${ep.port}`;
}
</script>
{#if server}
@@ -83,11 +103,45 @@
<span class="pair"><span class="k">status</span> <span class="v health-{effectiveHealth}">{effectiveHealth}</span></span>
<span class="pair"><span class="k">last seen</span> <span class="v">{ago(effectiveSeen)}</span></span>
</div>
{#if effectiveErr && effectiveHealth !== 'online'}
<div class="hero-error">RCON: {effectiveErr}</div>
{/if}
</section>
{#if effectiveHealth !== 'online' && integrations}
<section class="diag panel">
<h2>RCON diagnostic</h2>
<div class="diag-grid">
<div class="stat">
<div class="k">endpoint</div>
<div class="v mono">{endpointLabel(effectiveEndpoint)}</div>
</div>
<div class="stat">
<div class="k">RCON password</div>
<div class="v mono flag-{integrations.rcon ? 'ok' : 'bad'}">{integrations.rcon ? 'configured' : 'missing'}</div>
</div>
<div class="stat">
<div class="k">Komodo</div>
<div class="v mono flag-{integrations.komodo ? 'ok' : 'bad'}">{integrations.komodo ? 'configured' : 'missing'}</div>
</div>
<div class="stat">
<div class="k">timeout</div>
<div class="v mono">{integrations.rconTimeoutMs}ms</div>
</div>
</div>
{#if effectiveReason}
<div class="reason-row">
<span class="reason-tag reason-{effectiveReason}">{effectiveReason}</span>
<span class="reason-detail">{errorDetail}</span>
</div>
<div class="hint">{rconFixHint(effectiveReason)}</div>
{:else if effectiveErr}
<div class="reason-row">
<span class="reason-tag reason-legacy">error</span>
<span class="reason-detail">{effectiveErr}</span>
</div>
{/if}
</section>
{/if}
<section class="panel">
<h2>Roster</h2>
<PlayerRoster roster={effectiveRoster} health={effectiveHealth} />
@@ -101,8 +155,12 @@
<div class="v mono">{server.komodo_type} · {server.komodo_name}</div>
</div>
<div class="stat">
<div class="k">rcon</div>
<div class="v mono">{server.rcon_host ?? 'default'} · port {server.game_port ?? '—'}</div>
<div class="k">rcon override</div>
<div class="v mono">{server.rcon_host ?? '(default)'}</div>
</div>
<div class="stat">
<div class="k">game_port</div>
<div class="v mono">{server.game_port ?? '(unresolved)'}</div>
</div>
<div class="stat">
<div class="k">held by beta</div>
@@ -206,16 +264,6 @@
.v.health-online { color: var(--spark-500); }
.v.health-stale { color: var(--amber-500); }
.v.health-offline { color: var(--danger-500); }
.hero-error {
margin-top: 12px;
padding: 8px 12px;
background: rgba(255, 48, 80, 0.08);
border: 1px solid rgba(255, 48, 80, 0.25);
color: var(--danger-500);
font-family: var(--font-mono);
font-size: 12px;
border-radius: var(--r-sm);
}
.panel {
background: var(--cherry-925);
@@ -224,6 +272,9 @@
padding: 18px 20px;
margin-bottom: 16px;
}
.diag { border-color: rgba(255, 48, 80, 0.35); }
.diag h2 { color: var(--danger-500); }
h2 {
margin: 0 0 12px;
font-family: var(--font-body);
@@ -233,9 +284,9 @@
letter-spacing: 1.5px;
color: var(--ink-300);
}
.stats {
.stats, .diag-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
}
.stat {
@@ -250,8 +301,54 @@
text-transform: uppercase;
letter-spacing: 1px;
}
.stat .v { color: var(--ink-100); margin-top: 4px; font-size: 13px; }
.stat .v { color: var(--ink-100); margin-top: 4px; font-size: 13px; word-break: break-word; }
.mono { font-family: var(--font-mono); }
.flag-ok { color: var(--spark-500); }
.flag-bad { color: var(--danger-500); }
.reason-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 14px;
padding: 10px 14px;
background: rgba(255, 48, 80, 0.08);
border: 1px solid rgba(255, 48, 80, 0.25);
border-radius: var(--r-sm);
}
.reason-tag {
padding: 2px 8px;
border-radius: var(--r-sm);
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
border: 1px solid transparent;
flex-shrink: 0;
}
.reason-no-config { background: rgba(255, 176, 32, 0.15); border-color: rgba(255, 176, 32, 0.4); color: var(--amber-500); }
.reason-no-port { background: rgba(255, 176, 32, 0.15); border-color: rgba(255, 176, 32, 0.4); color: var(--amber-500); }
.reason-timeout { background: rgba(255, 48, 80, 0.15); border-color: rgba(255, 48, 80, 0.4); color: var(--danger-500); }
.reason-send-error { background: rgba(255, 48, 80, 0.15); border-color: rgba(255, 48, 80, 0.4); color: var(--danger-500); }
.reason-bad-reply { background: rgba(242, 230, 200, 0.1); border-color: rgba(242, 230, 200, 0.3); color: var(--cream); }
.reason-no-players { background: rgba(242, 230, 200, 0.1); border-color: rgba(242, 230, 200, 0.3); color: var(--cream); }
.reason-legacy { background: var(--cherry-875); border-color: var(--cherry-700); color: var(--ink-300); }
.reason-detail {
color: var(--ink-100);
font-family: var(--font-mono);
font-size: 12px;
}
.hint {
margin-top: 10px;
padding: 8px 14px;
font-family: var(--font-body);
font-size: 12px;
color: var(--cream-dim);
background: var(--cherry-875);
border-left: 3px solid var(--spark-500);
border-radius: var(--r-sm);
}
.row {
display: grid;
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { decodeRconError, rconFixHint } from '../../src/lib/ui/rconError.js';
describe('decodeRconError', () => {
it('splits reason:detail from a well-formed error', () => {
expect(decodeRconError('timeout:no reply within 3000ms')).toEqual({
reason: 'timeout',
detail: 'no reply within 3000ms'
});
});
it('preserves colons inside the detail portion', () => {
expect(decodeRconError('send-error:ENETUNREACH: no route to host 10.0.0.5:28960')).toEqual({
reason: 'send-error',
detail: 'ENETUNREACH: no route to host 10.0.0.5:28960'
});
});
it('recognizes every known reason code', () => {
for (const r of ['no-config', 'no-port', 'send-error', 'timeout', 'bad-reply', 'no-players'] as const) {
expect(decodeRconError(`${r}:whatever`)?.reason).toBe(r);
}
});
it('returns null for legacy free-text errors', () => {
expect(decodeRconError('rcon unreachable')).toBeNull();
expect(decodeRconError('unknown-reason:hello')).toBeNull();
expect(decodeRconError('')).toBeNull();
expect(decodeRconError(null)).toBeNull();
expect(decodeRconError(undefined)).toBeNull();
});
});
describe('rconFixHint', () => {
it('returns a non-empty hint for every failure reason', () => {
for (const r of ['no-config', 'no-port', 'send-error', 'timeout', 'bad-reply', 'no-players'] as const) {
expect(rconFixHint(r).length).toBeGreaterThan(0);
}
});
});