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
+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;