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:
2026-09-12 11:50:19 -04:00
co-authored by Claude Opus 4.7
parent d21e8187ff
commit 6aae72f508
5 changed files with 559 additions and 0 deletions
@@ -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
});
};