system-monitor: live pm2 log streaming — click a process name to tail its logs

- GET /api/system-monitor/pm2/logs?id=<pm_id>&lines=<n> — SSE that spawns
  `pm2 logs <id> --raw` (combined out+err, follows live) and streams each line.
  id validated numeric + passed as a spawn arg (no shell); killed on disconnect.
- Pm2View: clicking a process name drills into Pm2Logs (EventSource tail with a
  live pulse + back button); autoscrolls, capped at ~1200 lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 10:32:53 +00:00
co-authored by Claude Opus 4.8
parent 7f91c4f9bf
commit 67e6646bca
3 changed files with 136 additions and 2 deletions
@@ -199,3 +199,83 @@ systemMonitorRouter.get('/docker', async (ctx) => {
return ctx.json({ containers: [], error: err instanceof Error ? err.message : String(err) });
}
});
// GET /pm2/logs?id=<pm_id>&lines=<n> — SSE stream of a pm2 process's live logs (combined out+err via
// `pm2 logs <id> --raw`). id is validated numeric and passed as a spawn arg (no shell) — no injection.
systemMonitorRouter.get('/pm2/logs', (ctx) => {
const id = ctx.req.query('id') ?? '';
if (!/^\d+$/.test(id)) return ctx.text('numeric pm2 id 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 {
/* already gone */
}
try {
controller.close();
} catch {
/* already closed */
}
};
try {
proc = Bun.spawn(['pm2', 'logs', id, '--raw', '--lines', String(lines)], { stdout: 'pipe', stderr: 'pipe' });
} catch (err) {
send(`[error spawning pm2 logs: ${String(err)}]`);
controller.close();
return;
}
void (async () => {
const reader = (proc!.stdout as ReadableStream<Uint8Array>).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 {
/* stream ended */
} 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' },
});
});
@@ -0,0 +1,42 @@
import { useEffect, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient';
import { ChevronLeft } from 'lucide-react';
const MAX_LINES = 1200;
// Live pm2 logs for one process (combined out+err), streamed over SSE from /system-monitor/pm2/logs.
export const Pm2Logs = ({ id, name, onBack }: { id: number; name: string; onBack: () => void }) => {
const { token } = useClient();
const [lines, setLines] = useState<string[]>([]);
const scrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setLines([]);
const es = new EventSource(`/api/system-monitor/pm2/logs?id=${id}&lines=150&token=${encodeURIComponent(token ?? '')}`);
es.onmessage = (ev) => setLines((prev) => [...prev.slice(-(MAX_LINES - 1)), ev.data]);
return () => es.close();
}, [id, 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} /> pm2
</button>
<span className="font-mono text-sm text-foreground">{name}</span>
<span className="ml-auto inline-flex h-2 w-2 animate-pulse rounded-full bg-emerald-500" title="live" />
</div>
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto bg-black/30 p-3 font-mono text-xs text-foreground/80">
{lines.length === 0 ? (
<span className="text-muted-foreground">connecting</span>
) : (
lines.map((l, i) => <div key={i} className="whitespace-pre-wrap break-words">{l}</div>)
)}
</div>
</div>
);
};
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient';
import { fmtBytes, fmtDuration } from './shared';
import { Pm2Logs } from './Pm2Logs';
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';
@@ -26,6 +27,7 @@ export const Pm2View = () => {
const [error, setError] = useState<string | null>(null);
const [sortKey, setSortKey] = useState<SortKey>('cpuPct');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const [selected, setSelected] = useState<{ id: number; name: string } | null>(null);
const alive = useRef(true);
useEffect(() => {
@@ -66,10 +68,12 @@ export const Pm2View = () => {
return arr;
}, [procs, sortKey, sortDir]);
if (selected) return <Pm2Logs id={selected.id} name={selected.name} 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">pm2 · {procs?.length ?? 0} processes</div>
<div className="mb-3 text-xs text-muted-foreground">pm2 · {procs?.length ?? 0} processes · click a name 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="overflow-x-auto rounded-xl border border-border bg-card">
<table className="w-full text-left text-sm">
@@ -93,7 +97,15 @@ export const Pm2View = () => {
{sorted.map((p) => (
<tr key={p.id} className="border-t border-border/60">
<td className="p-3 text-right font-mono text-muted-foreground">{p.id}</td>
<td className="p-3 font-medium text-foreground">{p.name}</td>
<td className="p-3">
<button
type="button"
onClick={() => setSelected({ id: p.id, name: p.name })}
className="font-medium text-foreground hover:text-primary hover:underline"
>
{p.name}
</button>
</td>
<td className="p-3">
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className={`inline-block h-2 w-2 rounded-full ${statusColor(p.status)}`} />