Add per-server log viewer (console.log + games_sp.log)
New Logs section on the server detail page — tabbed viewer with
auto-refresh every 5s (toggleable), sticky-to-bottom scrolling, and a
manual refresh button. Two whitelisted files per server:
/root/zombies/t5/<name>/logs/console.log
/root/zombies/t5/<name>/logs/games_sp.log
Backend:
- src/lib/server/fleet/logs.ts — safe path resolution + tailLog()
that reads only the last 256 KB of the file, splits on newlines,
drops the (potentially partial) first line if we didn't start at
byte 0, and returns the last N lines with size + mtime + truncated.
Two path-traversal guards: server directory must be a real
subdirectory of SERVERS_ROOT, and the target must resolve inside
it.
- GET /api/servers/[name]/logs/[file]?lines=200 — whitelisted keys
only (console, games_sp), 400 on unknown keys, ok:true+missing:
true when the log file hasn't been written yet.
Frontend:
- LogPanel.svelte with tabs, auto-refresh checkbox, size/mtime meta,
and a <pre> body that snaps to bottom on refresh unless the user
has scrolled up.
- 7 unit tests covering path safety (including .. traversal) and
tail behavior (missing file → null, small file, oversized file
→ truncated, trailing newline handling).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getServer } from '$lib/server/fleet/servers.js';
|
||||
import { isLogKey, resolveLogPath, tailLog, KNOWN_LOGS } from '$lib/server/fleet/logs.js';
|
||||
|
||||
/**
|
||||
* Tail one of the whitelisted log files for a server.
|
||||
* GET /api/servers/<name>/logs/console?lines=200
|
||||
* GET /api/servers/<name>/logs/games_sp?lines=200
|
||||
*
|
||||
* Returns { ok, key, path, lines, size, mtime, truncated } on success,
|
||||
* { ok:false, reason } on failure. Unknown log keys are 400s; missing
|
||||
* files return ok:true with lines:[] and reason:"missing" so the UI can
|
||||
* render "log hasn't been written yet" without treating it as an error.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const name = params.name;
|
||||
const file = params.file;
|
||||
if (!name || !file) return json({ ok: false, reason: 'missing params' }, { status: 400 });
|
||||
|
||||
if (!isLogKey(file)) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
reason: 'unknown log key',
|
||||
allowed: Object.keys(KNOWN_LOGS)
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const row = getServer(name);
|
||||
if (!row) return json({ ok: false, reason: `unknown server: ${name}` }, { status: 404 });
|
||||
|
||||
const path = resolveLogPath(name, file);
|
||||
if (!path) return json({ ok: false, reason: 'path traversal rejected' }, { status: 400 });
|
||||
|
||||
const linesParam = url.searchParams.get('lines');
|
||||
const wanted = linesParam ? Number.parseInt(linesParam, 10) : 200;
|
||||
|
||||
const tail = await tailLog(path, wanted).catch((err) => {
|
||||
return { error: (err as Error).message };
|
||||
});
|
||||
|
||||
if (tail && 'error' in tail) {
|
||||
return json({ ok: false, reason: `read failed: ${tail.error}`, path }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!tail) {
|
||||
// File doesn't exist — normal case for a server that hasn't logged yet.
|
||||
return json({
|
||||
ok: true,
|
||||
key: file,
|
||||
path,
|
||||
lines: [],
|
||||
size: 0,
|
||||
mtime: null,
|
||||
truncated: false,
|
||||
missing: true
|
||||
});
|
||||
}
|
||||
|
||||
return json({
|
||||
ok: true,
|
||||
key: file,
|
||||
path,
|
||||
lines: tail.lines,
|
||||
size: tail.size,
|
||||
mtime: tail.mtime,
|
||||
truncated: tail.truncated,
|
||||
missing: false
|
||||
});
|
||||
};
|
||||
@@ -5,6 +5,7 @@
|
||||
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 LogPanel from '$ui/components/LogPanel.svelte';
|
||||
import { gametypeLabel } from '$ui/gametype.js';
|
||||
import { decodeRconError, rconFixHint } from '$ui/rconError.js';
|
||||
import { komodoFixHint, type KomodoPortFailure } from '$ui/komodoError.js';
|
||||
@@ -436,6 +437,17 @@
|
||||
<PlayerRoster roster={effectiveRoster} health={effectiveHealth} />
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Logs</h2>
|
||||
<LogPanel
|
||||
server={server.name}
|
||||
tabs={[
|
||||
{ key: 'console', label: 'console.log' },
|
||||
{ key: 'games_sp', label: 'games_sp.log' }
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Config</h2>
|
||||
<div class="stats">
|
||||
|
||||
Reference in New Issue
Block a user