feat(worker-logs): add worker run-log viewer page
Nieuwe /worker-logs pagina: een tabel van de laatste N (10/25/50/100) worker-runs met een inline detailpaneel dat de stream-json output van Claude Code als leesbare timeline toont (system-init, assistant-tekst, tool-calls/results, result-kaart). - lib/parse-worker-log.ts: pure parser — summarizeRunLog (tabel) + parseRunLog (timeline), discriminated-union events, server-side truncatie van grote tool-results. - lib/worker-logs.ts: server-only fs-toegang, leest uit WORKER_LOGS_DIR (read-only bind mount), naam-regex + pad-confinement, .gz support. - app/api/worker-logs[/[name]]: GET-routes, auth-guarded, force-dynamic. - app/worker-logs: server page + client view (tabel, N-selector, auto-refresh) + detail (timeline, auto-refresh tijdens in-progress run). Vereist een read-only bind mount van /srv/scrum4me/worker-logs in de ops-dashboard-container (docker-compose.yml + WORKER_LOGS_DIR in .env). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
84b3afbefa
commit
7e049ebdef
10 changed files with 1159 additions and 0 deletions
116
lib/worker-logs.ts
Normal file
116
lib/worker-logs.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// lib/worker-logs.ts
|
||||
//
|
||||
// Server-only filesystem access to the worker run-logs. The directory is
|
||||
// mounted read-only into the ops-dashboard container (see docker-compose.yml:
|
||||
// `/srv/scrum4me/worker-logs:/var/worker-logs:ro`). Path configurable via the
|
||||
// WORKER_LOGS_DIR env var.
|
||||
//
|
||||
// Only imported by server components and route handlers — never by a
|
||||
// 'use client' file.
|
||||
|
||||
import 'server-only'
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { summarizeRunLog, type RunLogSummary } from './parse-worker-log'
|
||||
|
||||
const WORKER_LOGS_DIR = process.env.WORKER_LOGS_DIR ?? '/var/worker-logs/idea'
|
||||
const RUNS_DIR = join(WORKER_LOGS_DIR, 'runs')
|
||||
|
||||
/** Selectable row counts for the table. */
|
||||
export const LIMIT_OPTIONS = [10, 25, 50, 100] as const
|
||||
const DEFAULT_LIMIT = 10
|
||||
|
||||
// Filenames are `$(date -u +%Y%m%dT%H%M%SZ).log` — no slashes, no dots beyond
|
||||
// the literal suffix, so this regex alone rules out path traversal.
|
||||
const NAME_RE = /^\d{8}T\d{6}Z\.log(\.gz)?$/
|
||||
|
||||
export type WorkerLogErrorCode = 'invalid' | 'not-found' | 'unavailable'
|
||||
|
||||
export class WorkerLogError extends Error {
|
||||
readonly code: WorkerLogErrorCode
|
||||
constructor(message: string, code: WorkerLogErrorCode) {
|
||||
super(message)
|
||||
this.name = 'WorkerLogError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
/** Clamp an arbitrary requested limit down to the largest allowed option. */
|
||||
export function clampLimit(n: number): number {
|
||||
if (!Number.isFinite(n)) return DEFAULT_LIMIT
|
||||
let chosen: number = DEFAULT_LIMIT
|
||||
for (const opt of LIMIT_OPTIONS) {
|
||||
if (n >= opt) chosen = opt
|
||||
}
|
||||
return chosen
|
||||
}
|
||||
|
||||
export function isValidLogName(name: string): boolean {
|
||||
return NAME_RE.test(name)
|
||||
}
|
||||
|
||||
function resolveLogPath(name: string): string {
|
||||
if (!isValidLogName(name)) {
|
||||
throw new WorkerLogError(`invalid log name: ${name}`, 'invalid')
|
||||
}
|
||||
const base = resolve(RUNS_DIR)
|
||||
const full = resolve(base, name)
|
||||
// Defense-in-depth: the regex already forbids traversal, but confirm anyway.
|
||||
if (full !== join(base, name)) {
|
||||
throw new WorkerLogError(`path escapes worker logs dir: ${name}`, 'invalid')
|
||||
}
|
||||
return full
|
||||
}
|
||||
|
||||
async function readLogFile(name: string): Promise<string> {
|
||||
const full = resolveLogPath(name)
|
||||
if (name.endsWith('.gz')) {
|
||||
const buf = await readFile(full)
|
||||
return gunzipSync(buf).toString('utf8')
|
||||
}
|
||||
return readFile(full, 'utf8')
|
||||
}
|
||||
|
||||
/** Newest-first summaries for the table. Sorts by filename, slices, then reads. */
|
||||
export async function listRunLogs(limit: number): Promise<RunLogSummary[]> {
|
||||
const n = clampLimit(limit)
|
||||
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = await readdir(RUNS_DIR)
|
||||
} catch (err) {
|
||||
throw new WorkerLogError(
|
||||
`cannot read worker logs dir ${RUNS_DIR}: ${(err as Error).message}`,
|
||||
'unavailable',
|
||||
)
|
||||
}
|
||||
|
||||
// Filename is `YYYYMMDDTHHMMSSZ` — lexicographic order == chronological order.
|
||||
// Sort + slice BEFORE touching file content (the dir holds ~12k files).
|
||||
const names = entries.filter(isValidLogName).sort().reverse().slice(0, n)
|
||||
|
||||
return Promise.all(
|
||||
names.map(async (name) => {
|
||||
try {
|
||||
return summarizeRunLog(await readLogFile(name), name)
|
||||
} catch {
|
||||
// A single unreadable / mid-rotation file must not break the table.
|
||||
return { ...summarizeRunLog('', name), status: 'unknown' as const, inProgress: false }
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** Raw contents of one run-log (gunzipped if needed). */
|
||||
export async function readRunLog(name: string): Promise<string> {
|
||||
try {
|
||||
return await readLogFile(name)
|
||||
} catch (err) {
|
||||
if (err instanceof WorkerLogError) throw err
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw new WorkerLogError(`log not found: ${name}`, 'not-found')
|
||||
}
|
||||
throw new WorkerLogError(`cannot read log ${name}: ${(err as Error).message}`, 'unavailable')
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue