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:
@@ -24,7 +24,8 @@ const SERVERS_COLUMNS: Array<{ name: string; ddl: string }> = [
|
|||||||
{ name: 'last_gametype', ddl: 'ALTER TABLE servers ADD COLUMN last_gametype TEXT' },
|
{ name: 'last_gametype', ddl: 'ALTER TABLE servers ADD COLUMN last_gametype TEXT' },
|
||||||
{ name: 'last_roster', ddl: 'ALTER TABLE servers ADD COLUMN last_roster TEXT' },
|
{ name: 'last_roster', ddl: 'ALTER TABLE servers ADD COLUMN last_roster TEXT' },
|
||||||
{ name: 'last_seen_at', ddl: 'ALTER TABLE servers ADD COLUMN last_seen_at TEXT' },
|
{ name: 'last_seen_at', ddl: 'ALTER TABLE servers ADD COLUMN last_seen_at TEXT' },
|
||||||
{ name: 'last_error', ddl: 'ALTER TABLE servers ADD COLUMN last_error TEXT' }
|
{ name: 'last_error', ddl: 'ALTER TABLE servers ADD COLUMN last_error TEXT' },
|
||||||
|
{ name: 'last_reply_snippet', ddl: 'ALTER TABLE servers ADD COLUMN last_reply_snippet TEXT' }
|
||||||
];
|
];
|
||||||
|
|
||||||
function migrateServers(handle: Database.Database): void {
|
function migrateServers(handle: Database.Database): void {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS servers (
|
|||||||
last_roster TEXT, -- JSON array of RconPlayer
|
last_roster TEXT, -- JSON array of RconPlayer
|
||||||
last_seen_at TEXT, -- ISO ts of last successful RCON reply
|
last_seen_at TEXT, -- ISO ts of last successful RCON reply
|
||||||
last_error TEXT, -- last RCON failure reason (cleared on success)
|
last_error TEXT, -- last RCON failure reason (cleared on success)
|
||||||
|
last_reply_snippet TEXT, -- hex+ASCII of last bad-reply UDP payload
|
||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export interface ServerRow {
|
|||||||
last_roster: string | null; // JSON array of RconPlayer, or null
|
last_roster: string | null; // JSON array of RconPlayer, or null
|
||||||
last_seen_at: string | null; // last successful RCON reply
|
last_seen_at: string | null; // last successful RCON reply
|
||||||
last_error: string | null; // cleared on success
|
last_error: string | null; // cleared on success
|
||||||
|
last_reply_snippet: string | null; // hex+ASCII of last bad-reply payload
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +127,8 @@ export function setServerField(
|
|||||||
'last_gametype',
|
'last_gametype',
|
||||||
'last_roster',
|
'last_roster',
|
||||||
'last_seen_at',
|
'last_seen_at',
|
||||||
'last_error'
|
'last_error',
|
||||||
|
'last_reply_snippet'
|
||||||
];
|
];
|
||||||
if (!allowed.includes(field)) throw new Error(`refusing to set ${field}`);
|
if (!allowed.includes(field)) throw new Error(`refusing to set ${field}`);
|
||||||
db().prepare(`UPDATE servers SET ${field} = ? WHERE name = ?`).run(value, name);
|
db().prepare(`UPDATE servers SET ${field} = ? WHERE name = ?`).run(value, name);
|
||||||
|
|||||||
@@ -231,7 +231,37 @@ export interface RconStatus {
|
|||||||
|
|
||||||
export type RconStatusResult =
|
export type RconStatusResult =
|
||||||
| { ok: true; endpoint: RconEndpoint; status: RconStatus }
|
| { ok: true; endpoint: RconEndpoint; status: RconStatus }
|
||||||
| { ok: false; endpoint: RconEndpoint | null; reason: RconFailure; detail: string };
|
| {
|
||||||
|
ok: false;
|
||||||
|
endpoint: RconEndpoint | null;
|
||||||
|
reason: RconFailure;
|
||||||
|
detail: string;
|
||||||
|
// Present on bad-reply: printable hex + ASCII dump of the raw UDP
|
||||||
|
// payload so the operator can see what the server actually said
|
||||||
|
// (wrong password body, unexpected dialect, etc).
|
||||||
|
rawReply?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RAW_REPLY_LIMIT = 512;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a Buffer as a short hex + printable-ASCII snippet — same layout as
|
||||||
|
* `hexdump -C`, capped at RAW_REPLY_LIMIT bytes so it fits in the UI panel.
|
||||||
|
*/
|
||||||
|
export function formatRawReply(buf: Buffer): string {
|
||||||
|
const bytes = buf.subarray(0, RAW_REPLY_LIMIT);
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let i = 0; i < bytes.length; i += 16) {
|
||||||
|
const chunk = bytes.subarray(i, i + 16);
|
||||||
|
const hex = Array.from(chunk).map((b) => b.toString(16).padStart(2, '0')).join(' ');
|
||||||
|
const ascii = Array.from(chunk).map((b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : '.')).join('');
|
||||||
|
lines.push(`${i.toString(16).padStart(4, '0')} ${hex.padEnd(47, ' ')} ${ascii}`);
|
||||||
|
}
|
||||||
|
if (buf.length > RAW_REPLY_LIMIT) {
|
||||||
|
lines.push(`… (${buf.length - RAW_REPLY_LIMIT} more bytes)`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full RCON status probe with structured diagnostics. Callers get either
|
* Full RCON status probe with structured diagnostics. Callers get either
|
||||||
@@ -275,18 +305,32 @@ export async function rconStatusResult(server: string): Promise<RconStatusResult
|
|||||||
ok: false,
|
ok: false,
|
||||||
endpoint: ep,
|
endpoint: ep,
|
||||||
reason: 'bad-reply',
|
reason: 'bad-reply',
|
||||||
detail: 'reply missing 0xff prefix — wrong port or non-Plutonium listener?'
|
detail: `reply missing 0xff prefix (${send.buf.length} bytes) — wrong port or non-Plutonium listener?`,
|
||||||
|
rawReply: formatRawReply(send.buf)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const info = parseInfoLine(text);
|
const info = parseInfoLine(text);
|
||||||
const players = parsePlayerCount(text);
|
const players = parsePlayerCount(text);
|
||||||
if (players === null) {
|
if (players === null) {
|
||||||
// Reply arrived but neither the print separator nor an info line
|
// Reply arrived but neither the print separator nor an info line
|
||||||
// showed up — usually a wrong-password `print` body ("Bad rcon…").
|
// showed up. Common wrong-password wordings vary across Plutonium
|
||||||
const detail = text.trim().startsWith('print') && /bad\s+rcon/i.test(text)
|
// builds ("Bad rcon_password", "Invalid password", "Bad password").
|
||||||
? 'wrong RCON password'
|
const trimmed = text.trim();
|
||||||
: `unparseable reply (${text.length} bytes)`;
|
const looksLikePrint = trimmed.startsWith('print');
|
||||||
return { ok: false, endpoint: ep, reason: 'bad-reply', detail };
|
const suggestsPassword = /(bad|invalid|wrong).{0,20}(rcon|password|pass)/i.test(text);
|
||||||
|
let detail: string;
|
||||||
|
if (looksLikePrint && suggestsPassword) {
|
||||||
|
detail = 'wrong RCON password';
|
||||||
|
} else if (looksLikePrint) {
|
||||||
|
// print body but we couldn't classify — show the first line
|
||||||
|
const firstLine = trimmed.split(/\r?\n/)[1]?.trim() ?? '';
|
||||||
|
detail = firstLine
|
||||||
|
? `print reply not recognized: "${firstLine.slice(0, 100)}"`
|
||||||
|
: `unparseable print reply (${text.length} bytes)`;
|
||||||
|
} else {
|
||||||
|
detail = `unparseable reply (${send.buf.length} bytes) — see snippet below`;
|
||||||
|
}
|
||||||
|
return { ok: false, endpoint: ep, reason: 'bad-reply', detail, rawReply: formatRawReply(send.buf) };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface ServerStatusEntry {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
endpoint: RconEndpoint | null;
|
endpoint: RconEndpoint | null;
|
||||||
reason: RconFailure | null;
|
reason: RconFailure | null;
|
||||||
|
replySnippet: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PipelineEvent =
|
export type PipelineEvent =
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ async function fanoutStatus(): Promise<void> {
|
|||||||
ok: false as const,
|
ok: false as const,
|
||||||
endpoint: null,
|
endpoint: null,
|
||||||
reason: 'send-error' as RconFailure,
|
reason: 'send-error' as RconFailure,
|
||||||
detail: (err as Error).message
|
detail: (err as Error).message,
|
||||||
|
rawReply: undefined as string | undefined
|
||||||
}));
|
}));
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ async function fanoutStatus(): Promise<void> {
|
|||||||
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);
|
||||||
|
setServerField(srv.name, 'last_reply_snippet', null);
|
||||||
entries[i] = {
|
entries[i] = {
|
||||||
server: srv.name,
|
server: srv.name,
|
||||||
players: result.status.players,
|
players: result.status.players,
|
||||||
@@ -72,14 +74,17 @@ async function fanoutStatus(): Promise<void> {
|
|||||||
lastSeenAt: now,
|
lastSeenAt: now,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
endpoint: result.endpoint,
|
endpoint: result.endpoint,
|
||||||
reason: null
|
reason: null,
|
||||||
|
replySnippet: 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 structured failure.
|
// to offline naturally), record the structured failure.
|
||||||
const encoded = encodeError(result.reason, result.detail);
|
const encoded = encodeError(result.reason, result.detail);
|
||||||
|
const snippet = result.rawReply ?? null;
|
||||||
setServerField(srv.name, 'last_checked_at', now);
|
setServerField(srv.name, 'last_checked_at', now);
|
||||||
setServerField(srv.name, 'last_error', encoded);
|
setServerField(srv.name, 'last_error', encoded);
|
||||||
|
setServerField(srv.name, 'last_reply_snippet', snippet);
|
||||||
entries[i] = {
|
entries[i] = {
|
||||||
server: srv.name,
|
server: srv.name,
|
||||||
players: srv.last_players,
|
players: srv.last_players,
|
||||||
@@ -90,7 +95,8 @@ async function fanoutStatus(): Promise<void> {
|
|||||||
lastSeenAt: srv.last_seen_at,
|
lastSeenAt: srv.last_seen_at,
|
||||||
lastError: encoded,
|
lastError: encoded,
|
||||||
endpoint: result.endpoint,
|
endpoint: result.endpoint,
|
||||||
reason: result.reason
|
reason: result.reason,
|
||||||
|
replySnippet: snippet
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface ServerStatus {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
endpoint: RconEndpoint | null;
|
endpoint: RconEndpoint | null;
|
||||||
reason: RconFailure | null;
|
reason: RconFailure | null;
|
||||||
|
replySnippet: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const statusByServer = writable<Map<string, ServerStatus>>(new Map());
|
export const statusByServer = writable<Map<string, ServerStatus>>(new Map());
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export async function GET() {
|
|||||||
...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),
|
||||||
|
replySnippet: s.last_reply_snippet,
|
||||||
// Derived endpoint — same logic as endpointFor() but without asking
|
// Derived endpoint — same logic as endpointFor() but without asking
|
||||||
// Komodo. If we don't have a port yet, endpoint is null.
|
// 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,
|
endpoint: s.game_port ? { host: s.rcon_host ?? rconHost, port: s.game_port } : null,
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { rconStatusResult } from '$lib/server/integrations/rcon.js';
|
||||||
|
import { getServer, setServerField } from '$lib/server/fleet/servers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On-demand RCON probe for a specific server. Same code path as the periodic
|
||||||
|
* sweep but callable from the UI so operators can iterate on RCON password /
|
||||||
|
* port / firewall changes without waiting for the next 30s tick.
|
||||||
|
*
|
||||||
|
* Mirrors the persist logic in worker.ts so the DB stays consistent with
|
||||||
|
* whatever the caller sees.
|
||||||
|
*/
|
||||||
|
export const POST: RequestHandler = async ({ params }) => {
|
||||||
|
const name = params.name;
|
||||||
|
if (!name) return json({ ok: false, error: 'missing server name' }, { status: 400 });
|
||||||
|
|
||||||
|
const row = getServer(name);
|
||||||
|
if (!row) return json({ ok: false, error: `unknown server: ${name}` }, { status: 404 });
|
||||||
|
|
||||||
|
const result = await rconStatusResult(name);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
setServerField(name, 'last_players', result.status.players);
|
||||||
|
setServerField(name, 'last_map', result.status.map);
|
||||||
|
setServerField(name, 'last_gametype', result.status.gametype);
|
||||||
|
setServerField(name, 'last_roster', JSON.stringify(result.status.roster));
|
||||||
|
setServerField(name, 'last_seen_at', now);
|
||||||
|
setServerField(name, 'last_checked_at', now);
|
||||||
|
setServerField(name, 'last_error', null);
|
||||||
|
setServerField(name, 'last_reply_snippet', null);
|
||||||
|
} else {
|
||||||
|
const encoded = `${result.reason}:${result.detail}`;
|
||||||
|
setServerField(name, 'last_checked_at', now);
|
||||||
|
setServerField(name, 'last_error', encoded);
|
||||||
|
setServerField(name, 'last_reply_snippet', result.rawReply ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(result);
|
||||||
|
};
|
||||||
@@ -42,6 +42,20 @@
|
|||||||
error?: string;
|
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 {
|
interface Server {
|
||||||
name: string;
|
name: string;
|
||||||
komodo_type: string;
|
komodo_type: string;
|
||||||
@@ -58,6 +72,7 @@
|
|||||||
roster: RosterPlayer[] | null;
|
roster: RosterPlayer[] | null;
|
||||||
health: Health;
|
health: Health;
|
||||||
endpoint: RconEndpoint | null;
|
endpoint: RconEndpoint | null;
|
||||||
|
replySnippet: string | null;
|
||||||
held: Array<{ target: string; slug: string }>;
|
held: Array<{ target: string; slug: string }>;
|
||||||
}
|
}
|
||||||
interface Integrations {
|
interface Integrations {
|
||||||
@@ -78,6 +93,12 @@
|
|||||||
let integrations = $state<Integrations | 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 ?? '');
|
||||||
|
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 () => {
|
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')]);
|
||||||
@@ -101,6 +122,9 @@
|
|||||||
let decoded = $derived(decodeRconError(effectiveErr));
|
let decoded = $derived(decodeRconError(effectiveErr));
|
||||||
let effectiveReason = $derived<RconFailure | null>(liveReason ?? decoded?.reason ?? null);
|
let effectiveReason = $derived<RconFailure | null>(liveReason ?? decoded?.reason ?? null);
|
||||||
let errorDetail = $derived(decoded?.detail ?? effectiveErr ?? '');
|
let errorDetail = $derived(decoded?.detail ?? effectiveErr ?? '');
|
||||||
|
let effectiveSnippet = $derived<string | null>(
|
||||||
|
rconProbe?.rawReply ?? live?.replySnippet ?? server?.replySnippet ?? null
|
||||||
|
);
|
||||||
|
|
||||||
function ago(iso: string | null): string {
|
function ago(iso: string | null): string {
|
||||||
if (!iso) return 'never';
|
if (!iso) return 'never';
|
||||||
@@ -116,11 +140,6 @@
|
|||||||
return `${ep.host}:${ep.port}`;
|
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> {
|
async function probeKomodo(): Promise<void> {
|
||||||
if (probing || !name) return;
|
if (probing || !name) return;
|
||||||
probing = true;
|
probing = true;
|
||||||
@@ -164,6 +183,27 @@
|
|||||||
diagnosing = false;
|
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>
|
</script>
|
||||||
|
|
||||||
{#if server}
|
{#if server}
|
||||||
@@ -223,6 +263,33 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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}
|
{#if !effectiveEndpoint && integrations.komodo}
|
||||||
<div class="probe-row">
|
<div class="probe-row">
|
||||||
<button class="probe-btn" onclick={probeKomodo} disabled={probing}>
|
<button class="probe-btn" onclick={probeKomodo} disabled={probing}>
|
||||||
|
|||||||
+25
-1
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { parseInfoLine, parsePlayerCount, parsePlayerRoster } from '../../src/lib/server/integrations/rcon.js';
|
import {
|
||||||
|
parseInfoLine,
|
||||||
|
parsePlayerCount,
|
||||||
|
parsePlayerRoster,
|
||||||
|
formatRawReply
|
||||||
|
} from '../../src/lib/server/integrations/rcon.js';
|
||||||
|
|
||||||
describe('parsePlayerCount', () => {
|
describe('parsePlayerCount', () => {
|
||||||
it('counts rows after --- --- separator (status dialect)', () => {
|
it('counts rows after --- --- separator (status dialect)', () => {
|
||||||
@@ -113,3 +118,22 @@ describe('parsePlayerRoster', () => {
|
|||||||
expect(parsePlayerRoster('nonsense')).toEqual([]);
|
expect(parsePlayerRoster('nonsense')).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatRawReply', () => {
|
||||||
|
it('renders a small buffer as hex + printable ASCII with an offset', () => {
|
||||||
|
const buf = Buffer.from([0xff, 0xff, 0xff, 0xff, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x0a]);
|
||||||
|
const out = formatRawReply(buf);
|
||||||
|
expect(out).toContain('0000');
|
||||||
|
expect(out).toContain('ff ff ff ff 70 72 69 6e 74 0a');
|
||||||
|
// Non-printable prefix bytes render as `.`; the printable tail spells "print".
|
||||||
|
expect(out).toContain('....print.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits into 16-byte rows and truncates past the limit', () => {
|
||||||
|
const buf = Buffer.alloc(600, 0x41); // "A" repeated
|
||||||
|
const out = formatRawReply(buf);
|
||||||
|
const lines = out.split('\n');
|
||||||
|
expect(lines.length).toBeGreaterThan(1);
|
||||||
|
expect(lines[lines.length - 1]).toMatch(/more bytes/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user