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,6 +1,6 @@
|
|||||||
import { createSocket } from 'node:dgram';
|
import { createSocket } from 'node:dgram';
|
||||||
import type { Socket } 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 { getServer, setServerField } from '../fleet/servers.js';
|
||||||
import { komodoResolveGamePort } from './komodo.js';
|
import { komodoResolveGamePort } from './komodo.js';
|
||||||
|
|
||||||
@@ -16,18 +16,33 @@ import { komodoResolveGamePort } from './komodo.js';
|
|||||||
|
|
||||||
const PREFIX = Buffer.from([0xff, 0xff, 0xff, 0xff]);
|
const PREFIX = Buffer.from([0xff, 0xff, 0xff, 0xff]);
|
||||||
|
|
||||||
async function sendRcon(host: string, port: number, command: string): Promise<Buffer | null> {
|
// Distinct RCON failure modes. Surfaced to the UI so the operator can tell
|
||||||
if (!rconConfigured()) return null;
|
// "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([
|
const payload = Buffer.concat([
|
||||||
PREFIX,
|
PREFIX,
|
||||||
Buffer.from(`rcon ${config.rcon.password} ${command}\n`, 'utf8')
|
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');
|
const sock: Socket = createSocket('udp4');
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
|
||||||
const finish = (val: Buffer | null): void => {
|
const finish = (val: SendResult): void => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
try {
|
try {
|
||||||
@@ -38,41 +53,52 @@ async function sendRcon(host: string, port: number, command: string): Promise<Bu
|
|||||||
resolve(val);
|
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) => {
|
sock.on('message', (msg) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
finish(msg);
|
finish({ ok: true, buf: msg });
|
||||||
});
|
});
|
||||||
sock.on('error', () => {
|
sock.on('error', (err) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
finish(null);
|
finish({ ok: false, reason: 'send-error', detail: err.message });
|
||||||
});
|
});
|
||||||
|
|
||||||
sock.send(payload, 0, payload.length, port, host, (err) => {
|
sock.send(payload, 0, payload.length, port, host, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
clearTimeout(timer);
|
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
|
* Resolve the RCON endpoint (host + port) for a server, filling
|
||||||
* `servers.game_port` from Komodo on first miss. Returns null when we
|
* `servers.game_port` from Komodo on first miss.
|
||||||
* still can't determine a port after asking Komodo.
|
|
||||||
*/
|
*/
|
||||||
async function endpointFor(server: string): Promise<{ host: string; port: number } | null> {
|
async function endpointFor(server: string): Promise<EndpointResolution> {
|
||||||
const row = getServer(server);
|
const row = getServer(server);
|
||||||
if (!row) return null;
|
if (!row) return { endpoint: null, reason: 'unknown-server' };
|
||||||
let port = row.game_port;
|
let port = row.game_port;
|
||||||
if (!port) {
|
if (!port) {
|
||||||
|
if (!komodoConfigured()) {
|
||||||
|
return { endpoint: null, reason: 'no-port-no-komodo' };
|
||||||
|
}
|
||||||
port = await komodoResolveGamePort(server);
|
port = await komodoResolveGamePort(server);
|
||||||
if (port) setServerField(server, 'game_port', port);
|
if (port) setServerField(server, 'game_port', port);
|
||||||
}
|
}
|
||||||
if (!port) return null;
|
if (!port) return { endpoint: null, reason: 'no-port-komodo-empty' };
|
||||||
return { host: row.rcon_host ?? config.rcon.host, port };
|
return { endpoint: { host: row.rcon_host ?? config.rcon.host, port } };
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripPrefix(buf: Buffer): string | null {
|
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> {
|
export async function rconGetPlayerCount(server: string): Promise<number | null> {
|
||||||
const ep = await endpointFor(server);
|
const res = await endpointFor(server);
|
||||||
if (!ep) return null;
|
if (!res.endpoint) return null;
|
||||||
const buf = await sendRcon(ep.host, ep.port, 'status');
|
const send = await sendRcon(res.endpoint.host, res.endpoint.port, 'status');
|
||||||
if (!buf) return null;
|
if (!send.ok) return null;
|
||||||
const text = stripPrefix(buf);
|
const text = stripPrefix(send.buf);
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
return parsePlayerCount(text);
|
return parsePlayerCount(text);
|
||||||
}
|
}
|
||||||
@@ -203,22 +229,73 @@ export interface RconStatus {
|
|||||||
roster: RconPlayer[];
|
roster: RconPlayer[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rconGetStatus(server: string): Promise<RconStatus | null> {
|
export type RconStatusResult =
|
||||||
const ep = await endpointFor(server);
|
| { ok: true; endpoint: RconEndpoint; status: RconStatus }
|
||||||
if (!ep) return null;
|
| { 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
|
// `getstatus` is anonymous on most CoD builds but Plutonium requires the
|
||||||
// authenticated rcon route for reliable results, so we ask via rcon.
|
// authenticated rcon route for reliable results, so we ask via rcon.
|
||||||
const buf = await sendRcon(ep.host, ep.port, 'getstatus');
|
const send = await sendRcon(ep.host, ep.port, 'getstatus');
|
||||||
if (!buf) return null;
|
if (!send.ok) {
|
||||||
const text = stripPrefix(buf);
|
return { ok: false, endpoint: ep, reason: send.reason, detail: send.detail };
|
||||||
if (!text) return null;
|
}
|
||||||
|
|
||||||
|
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 info = parseInfoLine(text);
|
||||||
const players = parsePlayerCount(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 {
|
return {
|
||||||
players,
|
ok: true,
|
||||||
map: info.mapname ?? info.sv_mapname ?? null,
|
endpoint: ep,
|
||||||
gametype: info.g_gametype ?? null,
|
status: {
|
||||||
roster: parsePlayerRoster(text)
|
players,
|
||||||
|
map: info.mapname ?? info.sv_mapname ?? null,
|
||||||
|
gametype: info.g_gametype ?? null,
|
||||||
|
roster: parsePlayerRoster(text)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
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';
|
import type { Health } from '../fleet/health.js';
|
||||||
|
|
||||||
// One in-process event bus, multiplexed by the SSE endpoints. Kept separate
|
// One in-process event bus, multiplexed by the SSE endpoints. Kept separate
|
||||||
@@ -15,6 +15,8 @@ export interface ServerStatusEntry {
|
|||||||
health: Health;
|
health: Health;
|
||||||
lastSeenAt: string | null;
|
lastSeenAt: string | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
|
endpoint: RconEndpoint | null;
|
||||||
|
reason: RconFailure | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PipelineEvent =
|
export type PipelineEvent =
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { sweep } from './sweep.js';
|
import { sweep } from './sweep.js';
|
||||||
import { emit } from './bus.js';
|
import { emit } from './bus.js';
|
||||||
import type { ServerStatusEntry } 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 { listServers, seedServerRegistry, setServerField } from '../fleet/servers.js';
|
||||||
import { classifyHealth } from '../fleet/health.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> {
|
async function fanoutStatus(): Promise<void> {
|
||||||
const servers = listServers();
|
const servers = listServers();
|
||||||
if (servers.length === 0) return;
|
if (servers.length === 0) return;
|
||||||
@@ -35,43 +45,52 @@ async function fanoutStatus(): Promise<void> {
|
|||||||
const i = cursor++;
|
const i = cursor++;
|
||||||
if (i >= servers.length) return;
|
if (i >= servers.length) return;
|
||||||
const srv = servers[i];
|
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();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
if (status) {
|
if (result.ok) {
|
||||||
// Persist last-observed state. last_seen_at is the health anchor.
|
// Persist last-observed state. last_seen_at is the health anchor.
|
||||||
setServerField(srv.name, 'last_players', status.players);
|
setServerField(srv.name, 'last_players', result.status.players);
|
||||||
setServerField(srv.name, 'last_map', status.map);
|
setServerField(srv.name, 'last_map', result.status.map);
|
||||||
setServerField(srv.name, 'last_gametype', status.gametype);
|
setServerField(srv.name, 'last_gametype', result.status.gametype);
|
||||||
setServerField(srv.name, 'last_roster', JSON.stringify(status.roster));
|
setServerField(srv.name, 'last_roster', JSON.stringify(result.status.roster));
|
||||||
setServerField(srv.name, 'last_seen_at', now);
|
setServerField(srv.name, 'last_seen_at', now);
|
||||||
setServerField(srv.name, 'last_checked_at', now);
|
setServerField(srv.name, 'last_checked_at', now);
|
||||||
setServerField(srv.name, 'last_error', null);
|
setServerField(srv.name, 'last_error', null);
|
||||||
entries[i] = {
|
entries[i] = {
|
||||||
server: srv.name,
|
server: srv.name,
|
||||||
players: status.players,
|
players: result.status.players,
|
||||||
map: status.map,
|
map: result.status.map,
|
||||||
gametype: status.gametype,
|
gametype: result.status.gametype,
|
||||||
roster: status.roster,
|
roster: result.status.roster,
|
||||||
health: classifyHealth(now, null),
|
health: classifyHealth(now, null),
|
||||||
lastSeenAt: now,
|
lastSeenAt: now,
|
||||||
lastError: null
|
lastError: null,
|
||||||
|
endpoint: result.endpoint,
|
||||||
|
reason: null
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// RCON unreachable — leave last_seen_at alone (health decays
|
// RCON unreachable — leave last_seen_at alone (health decays
|
||||||
// to offline naturally), record the failure reason.
|
// to offline naturally), record the structured failure.
|
||||||
const error = 'rcon unreachable';
|
const encoded = encodeError(result.reason, result.detail);
|
||||||
setServerField(srv.name, 'last_checked_at', now);
|
setServerField(srv.name, 'last_checked_at', now);
|
||||||
setServerField(srv.name, 'last_error', error);
|
setServerField(srv.name, 'last_error', encoded);
|
||||||
entries[i] = {
|
entries[i] = {
|
||||||
server: srv.name,
|
server: srv.name,
|
||||||
players: srv.last_players,
|
players: srv.last_players,
|
||||||
map: srv.last_map,
|
map: srv.last_map,
|
||||||
gametype: srv.last_gametype,
|
gametype: srv.last_gametype,
|
||||||
roster: srv.last_roster ? safeJson(srv.last_roster) : null,
|
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,
|
lastSeenAt: srv.last_seen_at,
|
||||||
lastError: error
|
lastError: encoded,
|
||||||
|
endpoint: result.endpoint,
|
||||||
|
reason: result.reason
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,12 @@ export interface RosterPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Health = 'online' | 'stale' | 'offline';
|
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 {
|
export interface ServerStatus {
|
||||||
server: string;
|
server: string;
|
||||||
@@ -20,6 +26,8 @@ export interface ServerStatus {
|
|||||||
health: Health;
|
health: Health;
|
||||||
lastSeenAt: string | null;
|
lastSeenAt: string | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
|
endpoint: RconEndpoint | null;
|
||||||
|
reason: RconFailure | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const statusByServer = writable<Map<string, ServerStatus>>(new Map());
|
export const statusByServer = writable<Map<string, ServerStatus>>(new Map());
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
last_error: string | null;
|
last_error: string | null;
|
||||||
roster: RosterPlayer[] | null;
|
roster: RosterPlayer[] | null;
|
||||||
health: Health;
|
health: Health;
|
||||||
|
endpoint: { host: string; port: number } | null;
|
||||||
held: HeldRow[];
|
held: HeldRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { listServers, heldByBeta } from '$lib/server/fleet/servers.js';
|
import { listServers, heldByBeta } from '$lib/server/fleet/servers.js';
|
||||||
import { classifyHealth } from '$lib/server/fleet/health.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';
|
import type { RconPlayer } from '$lib/server/integrations/rcon.js';
|
||||||
|
|
||||||
function safeJson<T>(raw: string | null): T | null {
|
function safeJson<T>(raw: string | null): T | null {
|
||||||
@@ -13,11 +14,23 @@ function safeJson<T>(raw: string | null): T | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
const rconHost = config.rcon.host;
|
||||||
const servers = listServers().map((s) => ({
|
const servers = listServers().map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
roster: safeJson<RconPlayer[]>(s.last_roster),
|
roster: safeJson<RconPlayer[]>(s.last_roster),
|
||||||
health: classifyHealth(s.last_seen_at, s.last_error),
|
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 }))
|
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 { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { statusByServer } from '$ui/stores/servers.js';
|
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 StatusDot from '$ui/components/StatusDot.svelte';
|
||||||
import PlayerRoster from '$ui/components/PlayerRoster.svelte';
|
import PlayerRoster from '$ui/components/PlayerRoster.svelte';
|
||||||
import { gametypeLabel } from '$ui/gametype.js';
|
import { gametypeLabel } from '$ui/gametype.js';
|
||||||
|
import { decodeRconError, rconFixHint } from '$ui/rconError.js';
|
||||||
|
|
||||||
interface Server {
|
interface Server {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -22,8 +23,15 @@
|
|||||||
last_error: string | null;
|
last_error: string | null;
|
||||||
roster: RosterPlayer[] | null;
|
roster: RosterPlayer[] | null;
|
||||||
health: Health;
|
health: Health;
|
||||||
|
endpoint: RconEndpoint | null;
|
||||||
held: Array<{ target: string; slug: string }>;
|
held: Array<{ target: string; slug: string }>;
|
||||||
}
|
}
|
||||||
|
interface Integrations {
|
||||||
|
rcon: boolean;
|
||||||
|
komodo: boolean;
|
||||||
|
rconTimeoutMs: number;
|
||||||
|
defaultRconHost: string;
|
||||||
|
}
|
||||||
interface DeploymentRow {
|
interface DeploymentRow {
|
||||||
id: string;
|
id: string;
|
||||||
action: string;
|
action: string;
|
||||||
@@ -33,14 +41,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let server = $state<Server | null>(null);
|
let server = $state<Server | null>(null);
|
||||||
|
let integrations = $state<Integrations | null>(null);
|
||||||
let history = $state<DeploymentRow[]>([]);
|
let history = $state<DeploymentRow[]>([]);
|
||||||
let name = $derived($page.params.name ?? '');
|
let name = $derived($page.params.name ?? '');
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const [srvRes, depRes] = await Promise.all([fetch('/api/servers'), fetch('/api/deployments')]);
|
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[] };
|
const depBody = (await depRes.json()) as { deployments: DeploymentRow[] };
|
||||||
server = srvBody.servers.find((s) => s.name === name) ?? null;
|
server = srvBody.servers.find((s) => s.name === name) ?? null;
|
||||||
|
integrations = srvBody.integrations;
|
||||||
history = depBody.deployments.filter((d) => d.servers.some((s) => s.server === name));
|
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 effectiveHealth = $derived<Health>(live?.health ?? server?.health ?? 'offline');
|
||||||
let effectiveSeen = $derived(live?.lastSeenAt ?? server?.last_seen_at ?? null);
|
let effectiveSeen = $derived(live?.lastSeenAt ?? server?.last_seen_at ?? null);
|
||||||
let effectiveErr = $derived(live?.lastError ?? server?.last_error ?? 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 {
|
function ago(iso: string | null): string {
|
||||||
if (!iso) return 'never';
|
if (!iso) return 'never';
|
||||||
@@ -61,6 +76,11 @@
|
|||||||
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
|
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
|
||||||
return `${Math.floor(d / 86_400_000)}d 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>
|
</script>
|
||||||
|
|
||||||
{#if server}
|
{#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">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>
|
<span class="pair"><span class="k">last seen</span> <span class="v">{ago(effectiveSeen)}</span></span>
|
||||||
</div>
|
</div>
|
||||||
{#if effectiveErr && effectiveHealth !== 'online'}
|
|
||||||
<div class="hero-error">RCON: {effectiveErr}</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
</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">
|
<section class="panel">
|
||||||
<h2>Roster</h2>
|
<h2>Roster</h2>
|
||||||
<PlayerRoster roster={effectiveRoster} health={effectiveHealth} />
|
<PlayerRoster roster={effectiveRoster} health={effectiveHealth} />
|
||||||
@@ -101,8 +155,12 @@
|
|||||||
<div class="v mono">{server.komodo_type} · {server.komodo_name}</div>
|
<div class="v mono">{server.komodo_type} · {server.komodo_name}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="k">rcon</div>
|
<div class="k">rcon override</div>
|
||||||
<div class="v mono">{server.rcon_host ?? 'default'} · port {server.game_port ?? '—'}</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>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="k">held by beta</div>
|
<div class="k">held by beta</div>
|
||||||
@@ -206,16 +264,6 @@
|
|||||||
.v.health-online { color: var(--spark-500); }
|
.v.health-online { color: var(--spark-500); }
|
||||||
.v.health-stale { color: var(--amber-500); }
|
.v.health-stale { color: var(--amber-500); }
|
||||||
.v.health-offline { color: var(--danger-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 {
|
.panel {
|
||||||
background: var(--cherry-925);
|
background: var(--cherry-925);
|
||||||
@@ -224,6 +272,9 @@
|
|||||||
padding: 18px 20px;
|
padding: 18px 20px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
.diag { border-color: rgba(255, 48, 80, 0.35); }
|
||||||
|
.diag h2 { color: var(--danger-500); }
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
@@ -233,9 +284,9 @@
|
|||||||
letter-spacing: 1.5px;
|
letter-spacing: 1.5px;
|
||||||
color: var(--ink-300);
|
color: var(--ink-300);
|
||||||
}
|
}
|
||||||
.stats {
|
.stats, .diag-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
.stat {
|
.stat {
|
||||||
@@ -250,8 +301,54 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
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); }
|
.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 {
|
.row {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user