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,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>
|
||||
Reference in New Issue
Block a user