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,102 @@
|
|||||||
|
import { open, stat } from 'node:fs/promises';
|
||||||
|
import { resolve, sep } from 'node:path';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
|
||||||
|
// Whitelisted log files exposed to the UI, mapped to their path relative
|
||||||
|
// to each server's root directory. Adding a new supported log = one entry
|
||||||
|
// here + one tab in LogPanel.svelte.
|
||||||
|
export const KNOWN_LOGS = {
|
||||||
|
console: 'logs/console.log',
|
||||||
|
games_sp: 'logs/games_sp.log'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type LogKey = keyof typeof KNOWN_LOGS;
|
||||||
|
|
||||||
|
export function isLogKey(v: string): v is LogKey {
|
||||||
|
return Object.prototype.hasOwnProperty.call(KNOWN_LOGS, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a per-server log path safely. Two guards:
|
||||||
|
* 1. The server directory itself must be a real subdirectory of
|
||||||
|
* SERVERS_ROOT — this catches `serverName = ".."` and similar.
|
||||||
|
* 2. The final target path must be inside that server directory — this
|
||||||
|
* catches escapes in the log filename (defensive; KNOWN_LOGS entries
|
||||||
|
* are hardcoded but keep the check for future additions).
|
||||||
|
* Returns null on any escape.
|
||||||
|
*/
|
||||||
|
export function resolveLogPath(serverName: string, key: LogKey): string | null {
|
||||||
|
const serversRoot = resolve(config.serversRoot);
|
||||||
|
const serverDir = resolve(serversRoot, serverName);
|
||||||
|
if (serverDir === serversRoot || !serverDir.startsWith(serversRoot + sep)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const target = resolve(serverDir, KNOWN_LOGS[key]);
|
||||||
|
if (target !== serverDir && !target.startsWith(serverDir + sep)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TailResult {
|
||||||
|
lines: string[];
|
||||||
|
size: number;
|
||||||
|
mtime: string; // ISO
|
||||||
|
truncated: boolean; // true when we didn't read from the start of the file
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TAIL_LINES = 200;
|
||||||
|
const MAX_TAIL_LINES = 2000;
|
||||||
|
const READ_WINDOW_BYTES = 256 * 1024; // 256 KB — plenty for a few hundred lines
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the last N lines of a file efficiently: only slurps up to 256 KB
|
||||||
|
* from the end of the file, splits on newlines, drops the (potentially
|
||||||
|
* partial) first line if we didn't start at byte 0.
|
||||||
|
*
|
||||||
|
* Returns null when the file doesn't exist yet — treat this as "log
|
||||||
|
* hasn't been written to yet" rather than an error.
|
||||||
|
*/
|
||||||
|
export async function tailLog(
|
||||||
|
path: string,
|
||||||
|
lines: number = DEFAULT_TAIL_LINES
|
||||||
|
): Promise<TailResult | null> {
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = await stat(path);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!st.isFile()) return null;
|
||||||
|
|
||||||
|
const wanted = Math.min(Math.max(1, Math.floor(lines)), MAX_TAIL_LINES);
|
||||||
|
const size = st.size;
|
||||||
|
const start = Math.max(0, size - READ_WINDOW_BYTES);
|
||||||
|
const length = size - start;
|
||||||
|
|
||||||
|
const buf = Buffer.alloc(length);
|
||||||
|
const fd = await open(path, 'r');
|
||||||
|
try {
|
||||||
|
if (length > 0) await fd.read(buf, 0, length, start);
|
||||||
|
} finally {
|
||||||
|
await fd.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = buf.toString('utf8');
|
||||||
|
let allLines = text.split(/\r?\n/);
|
||||||
|
// If we didn't read from position 0, the first "line" is almost
|
||||||
|
// certainly a mid-line fragment — drop it.
|
||||||
|
if (start > 0 && allLines.length > 0) allLines = allLines.slice(1);
|
||||||
|
// A trailing newline creates an empty final entry — drop it.
|
||||||
|
if (allLines.length > 0 && allLines[allLines.length - 1] === '') {
|
||||||
|
allLines = allLines.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lines: allLines.slice(-wanted),
|
||||||
|
size,
|
||||||
|
mtime: st.mtime.toISOString(),
|
||||||
|
truncated: start > 0
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
|
||||||
import StatusDot from '$ui/components/StatusDot.svelte';
|
import StatusDot from '$ui/components/StatusDot.svelte';
|
||||||
import PlayerRoster from '$ui/components/PlayerRoster.svelte';
|
import PlayerRoster from '$ui/components/PlayerRoster.svelte';
|
||||||
|
import LogPanel from '$ui/components/LogPanel.svelte';
|
||||||
import { gametypeLabel } from '$ui/gametype.js';
|
import { gametypeLabel } from '$ui/gametype.js';
|
||||||
import { decodeRconError, rconFixHint } from '$ui/rconError.js';
|
import { decodeRconError, rconFixHint } from '$ui/rconError.js';
|
||||||
import { komodoFixHint, type KomodoPortFailure } from '$ui/komodoError.js';
|
import { komodoFixHint, type KomodoPortFailure } from '$ui/komodoError.js';
|
||||||
@@ -436,6 +437,17 @@
|
|||||||
<PlayerRoster roster={effectiveRoster} health={effectiveHealth} />
|
<PlayerRoster roster={effectiveRoster} health={effectiveHealth} />
|
||||||
</section>
|
</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">
|
<section class="panel">
|
||||||
<h2>Config</h2>
|
<h2>Config</h2>
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
|
||||||
|
// The logs module reads config.serversRoot lazily, so set SERVERS_ROOT
|
||||||
|
// before importing it. Vitest imports the module once per file so we
|
||||||
|
// stash the root globally and reset between describe blocks.
|
||||||
|
const TMP_ROOT = resolve(tmpdir(), `ec-logs-test-${Date.now()}`);
|
||||||
|
process.env.SERVERS_ROOT = TMP_ROOT;
|
||||||
|
process.env.SHARED_SCRIPTS = TMP_ROOT;
|
||||||
|
process.env.REL_DEST = 'runtime/plutonium/storage/t5/raw/scripts/sp';
|
||||||
|
process.env.DB_PATH = resolve(TMP_ROOT, 'test.db');
|
||||||
|
|
||||||
|
const { resolveLogPath, tailLog, isLogKey } = await import(
|
||||||
|
'../../src/lib/server/fleet/logs.js'
|
||||||
|
);
|
||||||
|
|
||||||
|
const SERVER = 't5_test_1';
|
||||||
|
const LOG_DIR = resolve(TMP_ROOT, SERVER, 'logs');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mkdirSync(LOG_DIR, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(TMP_ROOT, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isLogKey', () => {
|
||||||
|
it('accepts whitelisted keys and rejects anything else', () => {
|
||||||
|
expect(isLogKey('console')).toBe(true);
|
||||||
|
expect(isLogKey('games_sp')).toBe(true);
|
||||||
|
expect(isLogKey('bogus')).toBe(false);
|
||||||
|
expect(isLogKey('../etc/passwd')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveLogPath', () => {
|
||||||
|
it('resolves whitelisted log keys under the server directory', () => {
|
||||||
|
const p = resolveLogPath(SERVER, 'console');
|
||||||
|
expect(p).toBe(resolve(TMP_ROOT, SERVER, 'logs/console.log'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a server name that would path-traverse', () => {
|
||||||
|
// Server name with .. tries to escape the servers root
|
||||||
|
const p = resolveLogPath('..', 'console');
|
||||||
|
// resolve(TMP_ROOT, '..', 'logs/console.log') escapes TMP_ROOT,
|
||||||
|
// so the guard against non-serverDir-prefixed paths kicks in.
|
||||||
|
// The check is against the resolved server dir; escaping should
|
||||||
|
// return null.
|
||||||
|
expect(p).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tailLog', () => {
|
||||||
|
it('returns null for a missing file (not an error)', async () => {
|
||||||
|
const path = resolve(LOG_DIR, 'console.log');
|
||||||
|
expect(await tailLog(path, 10)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the last N lines of a small file with truncated=false', async () => {
|
||||||
|
const path = resolve(LOG_DIR, 'console.log');
|
||||||
|
const body = Array.from({ length: 20 }, (_, i) => `line ${i}`).join('\n') + '\n';
|
||||||
|
writeFileSync(path, body);
|
||||||
|
|
||||||
|
const tail = await tailLog(path, 5);
|
||||||
|
expect(tail).not.toBeNull();
|
||||||
|
expect(tail?.lines).toEqual(['line 15', 'line 16', 'line 17', 'line 18', 'line 19']);
|
||||||
|
expect(tail?.truncated).toBe(false);
|
||||||
|
expect(tail?.size).toBe(body.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps at MAX_TAIL_LINES and marks truncated when reading past the window', async () => {
|
||||||
|
const path = resolve(LOG_DIR, 'big.log');
|
||||||
|
// 500 KB of 60-char lines — comfortably larger than the 256 KB read window.
|
||||||
|
const line = 'x'.repeat(59);
|
||||||
|
const body = Array.from({ length: 8500 }, (_, i) => `${i.toString().padStart(4, '0')}${line.slice(4)}`).join('\n');
|
||||||
|
writeFileSync(path, body);
|
||||||
|
|
||||||
|
const tail = await tailLog(path, 100);
|
||||||
|
expect(tail).not.toBeNull();
|
||||||
|
expect(tail!.truncated).toBe(true);
|
||||||
|
expect(tail!.lines.length).toBeLessThanOrEqual(100);
|
||||||
|
// Last line should be the highest-numbered one; first line of the tail
|
||||||
|
// is somewhere in the middle since we can only read the last 256 KB.
|
||||||
|
expect(tail!.lines[tail!.lines.length - 1].startsWith('8499')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the trailing empty line when file ends with a newline', async () => {
|
||||||
|
const path = resolve(LOG_DIR, 'trailing.log');
|
||||||
|
writeFileSync(path, 'a\nb\nc\n');
|
||||||
|
const tail = await tailLog(path, 10);
|
||||||
|
expect(tail?.lines).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user