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:
@@ -19,6 +19,7 @@
|
||||
last_error: string | null;
|
||||
roster: RosterPlayer[] | null;
|
||||
health: Health;
|
||||
endpoint: { host: string; port: number } | null;
|
||||
held: HeldRow[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user