Surface raw RCON reply bytes + Probe RCON button
"unparseable reply (34 bytes)" told us Plutonium answered but hid the actual bytes, forcing another guess-and-check round. rconStatusResult now emits a formatRawReply() hex + ASCII dump of the raw UDP payload on every bad-reply, persisted in a new last_reply_snippet column so it survives page loads. Wrong-password detection widens beyond the "bad rcon" phrasing to "(bad|invalid|wrong)…(rcon|password|pass)"; print-body replies that don't match get their first non-header line surfaced verbatim so at least *some* server-supplied text reaches the UI. Probe RCON button (POST /api/servers/[name]/probe-rcon) fires the same code path as the periodic sweep on demand, cuts the RCON iteration loop from 30s per attempt to sub-second, and refreshes the header/roster/ health when it succeeds. Snippet <details> auto-opens on bad-reply so the bytes are one glance away. migrateServers() picks up the new column automatically via the introspection path added last commit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,20 @@
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface RconProbeResult {
|
||||
ok: boolean;
|
||||
endpoint?: { host: string; port: number } | null;
|
||||
reason?: RconFailure;
|
||||
detail?: string;
|
||||
rawReply?: string;
|
||||
status?: {
|
||||
players: number;
|
||||
map: string | null;
|
||||
gametype: string | null;
|
||||
roster: RosterPlayer[];
|
||||
};
|
||||
}
|
||||
|
||||
interface Server {
|
||||
name: string;
|
||||
komodo_type: string;
|
||||
@@ -58,6 +72,7 @@
|
||||
roster: RosterPlayer[] | null;
|
||||
health: Health;
|
||||
endpoint: RconEndpoint | null;
|
||||
replySnippet: string | null;
|
||||
held: Array<{ target: string; slug: string }>;
|
||||
}
|
||||
interface Integrations {
|
||||
@@ -78,6 +93,12 @@
|
||||
let integrations = $state<Integrations | null>(null);
|
||||
let history = $state<DeploymentRow[]>([]);
|
||||
let name = $derived($page.params.name ?? '');
|
||||
let komodoProbe = $state<KomodoProbeResult | null>(null);
|
||||
let probing = $state(false);
|
||||
let netDiagnosis = $state<NetDiagnosis | null>(null);
|
||||
let diagnosing = $state(false);
|
||||
let rconProbe = $state<RconProbeResult | null>(null);
|
||||
let probingRcon = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
const [srvRes, depRes] = await Promise.all([fetch('/api/servers'), fetch('/api/deployments')]);
|
||||
@@ -101,6 +122,9 @@
|
||||
let decoded = $derived(decodeRconError(effectiveErr));
|
||||
let effectiveReason = $derived<RconFailure | null>(liveReason ?? decoded?.reason ?? null);
|
||||
let errorDetail = $derived(decoded?.detail ?? effectiveErr ?? '');
|
||||
let effectiveSnippet = $derived<string | null>(
|
||||
rconProbe?.rawReply ?? live?.replySnippet ?? server?.replySnippet ?? null
|
||||
);
|
||||
|
||||
function ago(iso: string | null): string {
|
||||
if (!iso) return 'never';
|
||||
@@ -116,11 +140,6 @@
|
||||
return `${ep.host}:${ep.port}`;
|
||||
}
|
||||
|
||||
let komodoProbe = $state<KomodoProbeResult | null>(null);
|
||||
let probing = $state(false);
|
||||
let netDiagnosis = $state<NetDiagnosis | null>(null);
|
||||
let diagnosing = $state(false);
|
||||
|
||||
async function probeKomodo(): Promise<void> {
|
||||
if (probing || !name) return;
|
||||
probing = true;
|
||||
@@ -164,6 +183,27 @@
|
||||
diagnosing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function probeRcon(): Promise<void> {
|
||||
if (probingRcon || !name) return;
|
||||
probingRcon = true;
|
||||
rconProbe = null;
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${encodeURIComponent(name)}/probe-rcon`, {
|
||||
method: 'POST'
|
||||
});
|
||||
rconProbe = (await res.json()) as RconProbeResult;
|
||||
// Refresh the server row so the header, roster, and health decay
|
||||
// reflect what the probe just wrote.
|
||||
const srvRes = await fetch('/api/servers');
|
||||
const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations };
|
||||
server = srvBody.servers.find((s) => s.name === name) ?? server;
|
||||
} catch (err) {
|
||||
rconProbe = { ok: false, reason: 'send-error', detail: (err as Error).message };
|
||||
} finally {
|
||||
probingRcon = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if server}
|
||||
@@ -223,6 +263,33 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if effectiveSnippet}
|
||||
<details class="snippet-details" open={effectiveReason === 'bad-reply'}>
|
||||
<summary>raw reply ({effectiveSnippet.split('\n').length - 1} lines)</summary>
|
||||
<pre class="snippet">{effectiveSnippet}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
{#if effectiveEndpoint}
|
||||
<div class="probe-row">
|
||||
<button class="probe-btn" onclick={probeRcon} disabled={probingRcon}>
|
||||
{probingRcon ? 'probing rcon…' : 'probe rcon'}
|
||||
</button>
|
||||
<span class="probe-hint">Send a fresh RCON getstatus to {effectiveEndpoint.host}:{effectiveEndpoint.port} now.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if rconProbe?.ok}
|
||||
<div class="probe-result probe-ok">
|
||||
<span class="probe-tag ok">connected</span>
|
||||
<span class="probe-detail">
|
||||
{rconProbe.status?.players ?? '?'} players
|
||||
{#if rconProbe.status?.map} · map <strong>{rconProbe.status.map}</strong>{/if}
|
||||
{#if rconProbe.status?.gametype} · {rconProbe.status.gametype}{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !effectiveEndpoint && integrations.komodo}
|
||||
<div class="probe-row">
|
||||
<button class="probe-btn" onclick={probeKomodo} disabled={probing}>
|
||||
|
||||
Reference in New Issue
Block a user