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
+276
View File
@@ -0,0 +1,276 @@
<script lang="ts">
import { onDestroy, tick } from 'svelte';
interface TabDef {
key: string; // matches the log key in KNOWN_LOGS
label: string; // human-facing tab name
}
interface Props {
server: string;
tabs: TabDef[];
lines?: number;
pollMs?: number;
}
let { server, tabs, lines = 200, pollMs = 5000 }: Props = $props();
interface LogResponse {
ok: boolean;
key?: string;
path?: string;
lines?: string[];
size?: number;
mtime?: string | null;
truncated?: boolean;
missing?: boolean;
reason?: string;
}
let active = $state<string>('');
let data = $state<Record<string, LogResponse | null>>({});
// Default to the first tab once props are populated; don't overwrite
// the user's later selection.
$effect(() => {
if (!active && tabs.length > 0) active = tabs[0].key;
});
let loading = $state(false);
let autoRefresh = $state(true);
let pollTimer: ReturnType<typeof setInterval> | null = null;
let bodyEl = $state<HTMLPreElement | null>(null);
let stickToBottom = $state(true);
async function fetchLog(key: string): Promise<void> {
loading = true;
try {
const res = await fetch(
`/api/servers/${encodeURIComponent(server)}/logs/${encodeURIComponent(key)}?lines=${lines}`
);
const body = (await res.json()) as LogResponse;
data = { ...data, [key]: body };
} catch (err) {
data = { ...data, [key]: { ok: false, reason: (err as Error).message } };
} finally {
loading = false;
if (stickToBottom) {
await tick();
if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
}
}
}
function onScroll(): void {
if (!bodyEl) return;
// If the user scrolls up more than one screen, stop auto-scrolling on
// refresh; snap back on when they scroll to the bottom.
const nearBottom = bodyEl.scrollHeight - bodyEl.scrollTop - bodyEl.clientHeight < 40;
stickToBottom = nearBottom;
}
function switchTab(key: string): void {
active = key;
if (!data[key]) void fetchLog(key);
}
function schedulePoll(): void {
if (pollTimer) clearInterval(pollTimer);
if (autoRefresh) {
pollTimer = setInterval(() => {
if (active) void fetchLog(active);
}, pollMs);
}
}
function toggleAuto(): void {
autoRefresh = !autoRefresh;
schedulePoll();
}
// Initial load
$effect(() => {
if (active && !data[active]) void fetchLog(active);
});
// Reschedule when autoRefresh or active changes
$effect(() => {
schedulePoll();
});
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
});
function ago(iso: string | null | undefined): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
if (d < 60_000) return `${Math.max(1, Math.floor(d / 1000))}s ago`;
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
return `${Math.floor(d / 86_400_000)}d ago`;
}
function fmtBytes(n: number | undefined): string {
if (n === undefined || n === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(n) / Math.log(1024));
const value = n / Math.pow(1024, i);
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`;
}
let current = $derived(data[active] ?? null);
</script>
<div class="log-panel">
<div class="tabs">
{#each tabs as t (t.key)}
<button class="tab" class:on={active === t.key} onclick={() => switchTab(t.key)}>
{t.label}
</button>
{/each}
<div class="right">
{#if current?.mtime}
<span class="meta">{fmtBytes(current.size)} · updated {ago(current.mtime)}</span>
{/if}
<label class="auto">
<input type="checkbox" checked={autoRefresh} onchange={toggleAuto} />
auto {pollMs / 1000}s
</label>
<button class="refresh" onclick={() => fetchLog(active)} disabled={loading}>
{loading ? '…' : 'refresh'}
</button>
</div>
</div>
{#if !current}
<div class="empty">loading…</div>
{:else if !current.ok}
<div class="error">error: {current.reason ?? 'unknown'}</div>
{:else if current.missing}
<div class="empty">
log file not written yet
{#if current.path}<br /><code>{current.path}</code>{/if}
</div>
{:else if !current.lines || current.lines.length === 0}
<div class="empty">log is empty</div>
{:else}
<pre class="body" bind:this={bodyEl} onscroll={onScroll}>{current.lines.join('\n')}</pre>
{#if current.truncated}
<div class="foot">showing last {current.lines.length} lines (file is larger)</div>
{:else}
<div class="foot">{current.lines.length} line{current.lines.length === 1 ? '' : 's'}</div>
{/if}
{/if}
</div>
<style>
.log-panel {
display: flex;
flex-direction: column;
gap: 8px;
}
.tabs {
display: flex;
gap: 4px;
align-items: center;
border-bottom: 1px solid var(--cherry-700);
padding-bottom: 6px;
}
.tab {
background: transparent;
color: var(--ink-300);
border: 1px solid transparent;
border-radius: var(--r-sm) var(--r-sm) 0 0;
padding: 6px 12px;
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.tab:hover { color: var(--cream); background: var(--cherry-875); }
.tab.on {
color: var(--cream);
background: var(--cherry-875);
border-color: var(--cherry-700);
border-bottom-color: var(--cherry-875);
}
.right {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
}
.meta {
color: var(--ink-500);
font-family: var(--font-mono);
font-size: 11px;
}
.auto {
display: flex;
align-items: center;
gap: 4px;
color: var(--ink-500);
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
user-select: none;
}
.auto input { accent-color: var(--spark-500); }
.refresh {
background: var(--cherry-875);
color: var(--ink-100);
border: 1px solid var(--cherry-700);
border-radius: var(--r-sm);
padding: 4px 10px;
font-family: var(--font-body);
font-size: 11px;
cursor: pointer;
}
.refresh:hover:not(:disabled) {
border-color: var(--spark-500);
box-shadow: var(--spark-glow);
}
.refresh:disabled { opacity: 0.4; cursor: default; }
.body {
margin: 0;
padding: 12px 14px;
background: var(--cherry-975);
border: 1px solid var(--cherry-700);
border-radius: var(--r-sm);
color: var(--ink-300);
font-family: var(--font-mono);
font-size: 11.5px;
line-height: 1.55;
max-height: 520px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.empty, .error {
padding: 24px 14px;
text-align: center;
font-family: var(--font-mono);
font-size: 12px;
border-radius: var(--r-sm);
}
.empty {
color: var(--ink-500);
background: var(--cherry-925);
border: 1px dashed var(--cherry-700);
}
.empty code {
color: var(--cream-dim);
font-size: 11px;
}
.error {
color: var(--danger-500);
background: rgba(255, 48, 80, 0.08);
border: 1px solid rgba(255, 48, 80, 0.25);
}
.foot {
color: var(--ink-500);
font-family: var(--font-mono);
font-size: 10.5px;
text-align: right;
}
</style>