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:
2026-09-12 11:12:50 -04:00
co-authored by Claude Opus 4.7
parent a5b9733bd0
commit 8affb11b57
11 changed files with 207 additions and 18 deletions
+25 -1
View File
@@ -1,5 +1,10 @@
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', () => {
it('counts rows after --- --- separator (status dialect)', () => {
@@ -113,3 +118,22 @@ describe('parsePlayerRoster', () => {
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/);
});
});