system-monitor: black log pane + live docker container logs

- Shared LogStream component (solid-black <pre> terminal pane) replaces Pm2Logs;
  pm2 and docker both drill into it.
- GET /api/system-monitor/docker/logs?id=<container>&lines=<n> — SSE of
  `docker logs -f` (combined stdout+stderr); id charset-validated + spawn arg.
- DockerView: container cards are now clickable → live logs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 10:42:02 +00:00
co-authored by Claude Opus 4.8
parent 67e6646bca
commit f3108f6d83
4 changed files with 154 additions and 5 deletions
@@ -279,3 +279,88 @@ systemMonitorRouter.get('/pm2/logs', (ctx) => {
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' },
});
});
// GET /docker/logs?id=<container>&lines=<n> — SSE stream of a container's live logs (docker logs -f,
// combined stdout+stderr). id validated to a docker id/name charset + passed as a spawn arg (no shell).
systemMonitorRouter.get('/docker/logs', (ctx) => {
const id = ctx.req.query('id') ?? '';
if (!/^[a-zA-Z0-9][\w.-]{0,127}$/.test(id)) return ctx.text('valid container id/name required', 400);
const lines = Math.min(1000, Math.max(0, Number(ctx.req.query('lines')) || 100));
const enc = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let proc: ReturnType<typeof Bun.spawn> | null = null;
let closed = false;
const send = (line: string) => {
try {
controller.enqueue(enc.encode(`data: ${line}\n\n`));
} catch {
/* closed */
}
};
const cleanup = () => {
if (closed) return;
closed = true;
clearInterval(hb);
try {
proc?.kill();
} catch {
/* gone */
}
try {
controller.close();
} catch {
/* closed */
}
};
try {
proc = Bun.spawn(['docker', 'logs', '-f', '--tail', String(lines), id], { stdout: 'pipe', stderr: 'pipe' });
} catch (err) {
send(`[error spawning docker logs: ${String(err)}]`);
controller.close();
return;
}
const pump = async (rs: ReadableStream<Uint8Array> | null) => {
if (!rs) return;
const reader = rs.getReader();
const dec = new TextDecoder();
let buf = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const parts = buf.split('\n');
buf = parts.pop() ?? '';
for (const l of parts) send(l);
}
} catch {
/* ended */
}
};
// Containers log to both stdout and stderr; stream both.
void Promise.all([
pump(proc.stdout as ReadableStream<Uint8Array>),
pump(proc.stderr as ReadableStream<Uint8Array>),
]).finally(cleanup);
const hb = setInterval(() => {
try {
controller.enqueue(enc.encode(': hb\n\n'));
} catch {
/* closed */
}
}, 15_000);
ctx.req.raw.signal.addEventListener('abort', cleanup);
setTimeout(cleanup, 60 * 60 * 1000);
},
});
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' },
});
});
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient';
import { LogStream } from './LogStream';
type Container = { id: string; name: string; image: string; state: string; status: string; ports: string };
@@ -10,6 +11,7 @@ export const DockerView = () => {
const { get } = useClient();
const [containers, setContainers] = useState<Container[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<{ id: string; name: string } | null>(null);
const alive = useRef(true);
useEffect(() => {
@@ -31,14 +33,29 @@ export const DockerView = () => {
};
}, []);
if (selected)
return (
<LogStream
streamUrl={`/api/system-monitor/docker/logs?id=${encodeURIComponent(selected.id)}&lines=150`}
title={selected.name}
backLabel="docker"
onBack={() => setSelected(null)}
/>
);
return (
<div className="h-full w-full overflow-y-auto p-4 md:p-6">
<div className="mx-auto max-w-5xl">
<div className="mb-3 text-xs text-muted-foreground">docker · {containers?.length ?? 0} running</div>
<div className="mb-3 text-xs text-muted-foreground">docker · {containers?.length ?? 0} running · click a container for live logs</div>
{error && <div className="mb-3 rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-500">{error}</div>}
<div className="flex flex-col gap-2">
{(containers ?? []).map((c) => (
<div key={c.id} className="rounded-xl border border-border bg-card p-3">
<button
key={c.id}
type="button"
onClick={() => setSelected({ id: c.id, name: c.name })}
className="rounded-xl border border-border bg-card p-3 text-left transition-colors hover:border-primary/40 hover:bg-muted/40"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${stateColor(c.state)}`} />
@@ -50,7 +67,7 @@ export const DockerView = () => {
{c.ports && (
<div className="mt-1 truncate font-mono text-xs text-muted-foreground/70" title={c.ports}>{c.ports}</div>
)}
</div>
</button>
))}
{containers && containers.length === 0 && <p className="text-sm text-muted-foreground">no running containers</p>}
</div>
@@ -0,0 +1,39 @@
import { useEffect, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient';
import { ChevronLeft } from 'lucide-react';
const MAX_LINES = 1200;
// Generic live log viewer: SSE-tails `${streamUrl}&token=…` (server sends one `data:` frame per line),
// on a solid-black terminal-style pane. Used for both pm2 and docker logs.
export const LogStream = ({ streamUrl, title, backLabel, onBack }: { streamUrl: string; title: string; backLabel: string; onBack: () => void }) => {
const { token } = useClient();
const [lines, setLines] = useState<string[]>([]);
const scrollRef = useRef<HTMLPreElement | null>(null);
useEffect(() => {
setLines([]);
const es = new EventSource(`${streamUrl}&token=${encodeURIComponent(token ?? '')}`);
es.onmessage = (ev) => setLines((prev) => [...prev.slice(-(MAX_LINES - 1)), ev.data]);
return () => es.close();
}, [streamUrl, token]);
useEffect(() => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight);
}, [lines]);
return (
<div className="flex h-full w-full flex-col">
<div className="flex shrink-0 items-center gap-2 border-b border-border p-3">
<button type="button" onClick={onBack} className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ChevronLeft size={16} /> {backLabel}
</button>
<span className="truncate font-mono text-sm text-foreground">{title}</span>
<span className="ml-auto inline-flex h-2 w-2 animate-pulse rounded-full bg-emerald-500" title="live" />
</div>
<pre ref={scrollRef} className="m-0 min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words bg-black p-3 font-mono text-xs text-neutral-200">
{lines.length === 0 ? <span className="text-neutral-500">connecting</span> : lines.join('\n')}
</pre>
</div>
);
};
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient';
import { fmtBytes, fmtDuration } from './shared';
import { Pm2Logs } from './Pm2Logs';
import { LogStream } from './LogStream';
type Proc = { id: number; name: string; status: string; pid: number | null; cpuPct: number; memBytes: number; restarts: number; uptimeMs: number };
type SortKey = 'id' | 'name' | 'status' | 'cpuPct' | 'memBytes' | 'restarts' | 'uptimeMs' | 'pid';
@@ -68,7 +68,15 @@ export const Pm2View = () => {
return arr;
}, [procs, sortKey, sortDir]);
if (selected) return <Pm2Logs id={selected.id} name={selected.name} onBack={() => setSelected(null)} />;
if (selected)
return (
<LogStream
streamUrl={`/api/system-monitor/pm2/logs?id=${selected.id}&lines=150`}
title={selected.name}
backLabel="pm2"
onBack={() => setSelected(null)}
/>
);
return (
<div className="h-full w-full overflow-y-auto p-4 md:p-6">