From 7b4e6e0565d8b30717d2e848d434229a1fe814e9 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 12 Sep 2026 09:06:27 -0400 Subject: [PATCH] Surface structured RCON failure reasons in the fleet UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ":" 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 --- src/lib/server/integrations/rcon.ts | 143 +++++++++++++++++++------ src/lib/server/pipeline/bus.ts | 4 +- src/lib/server/pipeline/worker.ts | 53 ++++++--- src/lib/ui/rconError.ts | 43 ++++++++ src/lib/ui/stores/servers.ts | 8 ++ src/routes/+page.svelte | 1 + src/routes/api/servers/+server.ts | 15 ++- src/routes/servers/[name]/+page.svelte | 137 +++++++++++++++++++---- tests/unit/rconError.test.ts | 40 +++++++ 9 files changed, 372 insertions(+), 72 deletions(-) create mode 100644 src/lib/ui/rconError.ts create mode 100644 tests/unit/rconError.test.ts diff --git a/src/lib/server/integrations/rcon.ts b/src/lib/server/integrations/rcon.ts index fe444a7..7150dba 100644 --- a/src/lib/server/integrations/rcon.ts +++ b/src/lib/server/integrations/rcon.ts @@ -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 { - 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 { + 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((resolve) => { + return new Promise((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 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 { 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 { - 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 { - 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 { + 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) + } }; } diff --git a/src/lib/server/pipeline/bus.ts b/src/lib/server/pipeline/bus.ts index f1ed8ef..2818eca 100644 --- a/src/lib/server/pipeline/bus.ts +++ b/src/lib/server/pipeline/bus.ts @@ -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 = diff --git a/src/lib/server/pipeline/worker.ts b/src/lib/server/pipeline/worker.ts index 2a0a948..dcf3d63 100644 --- a/src/lib/server/pipeline/worker.ts +++ b/src/lib/server/pipeline/worker.ts @@ -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 { } } +/** + * 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 { const servers = listServers(); if (servers.length === 0) return; @@ -35,43 +45,52 @@ async function fanoutStatus(): Promise { 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 }; } } diff --git a/src/lib/ui/rconError.ts b/src/lib/ui/rconError.ts new file mode 100644 index 0000000..4e7180b --- /dev/null +++ b/src/lib/ui/rconError.ts @@ -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 ":" 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.'; + } +} diff --git a/src/lib/ui/stores/servers.ts b/src/lib/ui/stores/servers.ts index 0b9332f..33e77e4 100644 --- a/src/lib/ui/stores/servers.ts +++ b/src/lib/ui/stores/servers.ts @@ -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>(new Map()); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f6a7113..c7fab8f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -19,6 +19,7 @@ last_error: string | null; roster: RosterPlayer[] | null; health: Health; + endpoint: { host: string; port: number } | null; held: HeldRow[]; } diff --git a/src/routes/api/servers/+server.ts b/src/routes/api/servers/+server.ts index 2121a83..85a998d 100644 --- a/src/routes/api/servers/+server.ts +++ b/src/routes/api/servers/+server.ts @@ -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(raw: string | null): T | null { @@ -13,11 +14,23 @@ function safeJson(raw: string | null): T | null { } export async function GET() { + const rconHost = config.rcon.host; const servers = listServers().map((s) => ({ ...s, roster: safeJson(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 + } + }); } diff --git a/src/routes/servers/[name]/+page.svelte b/src/routes/servers/[name]/+page.svelte index 41281e2..7647c74 100644 --- a/src/routes/servers/[name]/+page.svelte +++ b/src/routes/servers/[name]/+page.svelte @@ -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(null); + let integrations = $state(null); let history = $state([]); 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(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(live?.endpoint ?? server?.endpoint ?? null); + let liveReason = $derived(live?.reason ?? null); + let decoded = $derived(decodeRconError(effectiveErr)); + let effectiveReason = $derived(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}`; + } {#if server} @@ -83,11 +103,45 @@ status {effectiveHealth} last seen {ago(effectiveSeen)} - {#if effectiveErr && effectiveHealth !== 'online'} -
RCON: {effectiveErr}
- {/if} + {#if effectiveHealth !== 'online' && integrations} +
+

RCON diagnostic

+
+
+
endpoint
+
{endpointLabel(effectiveEndpoint)}
+
+
+
RCON password
+
{integrations.rcon ? 'configured' : 'missing'}
+
+
+
Komodo
+
{integrations.komodo ? 'configured' : 'missing'}
+
+
+
timeout
+
{integrations.rconTimeoutMs}ms
+
+
+ + {#if effectiveReason} +
+ {effectiveReason} + {errorDetail} +
+
{rconFixHint(effectiveReason)}
+ {:else if effectiveErr} +
+ error + {effectiveErr} +
+ {/if} +
+ {/if} +

Roster

@@ -101,8 +155,12 @@
{server.komodo_type} · {server.komodo_name}
-
rcon
-
{server.rcon_host ?? 'default'} · port {server.game_port ?? '—'}
+
rcon override
+
{server.rcon_host ?? '(default)'}
+
+
+
game_port
+
{server.game_port ?? '(unresolved)'}
held by beta
@@ -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; diff --git a/tests/unit/rconError.test.ts b/tests/unit/rconError.test.ts new file mode 100644 index 0000000..d5de1df --- /dev/null +++ b/tests/unit/rconError.test.ts @@ -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); + } + }); +});