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:
@@ -199,3 +199,83 @@ systemMonitorRouter.get('/docker', async (ctx) => {
|
|||||||
return ctx.json({ containers: [], error: err instanceof Error ? err.message : String(err) });
|
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 { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { fmtBytes, fmtDuration } from './shared';
|
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 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';
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [sortKey, setSortKey] = useState<SortKey>('cpuPct');
|
const [sortKey, setSortKey] = useState<SortKey>('cpuPct');
|
||||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||||
|
const [selected, setSelected] = useState<{ id: number; name: string } | null>(null);
|
||||||
const alive = useRef(true);
|
const alive = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -66,10 +68,12 @@ export const Pm2View = () => {
|
|||||||
return arr;
|
return arr;
|
||||||
}, [procs, sortKey, sortDir]);
|
}, [procs, sortKey, sortDir]);
|
||||||
|
|
||||||
|
if (selected) return <Pm2Logs id={selected.id} name={selected.name} onBack={() => setSelected(null)} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full overflow-y-auto p-4 md:p-6">
|
<div className="h-full w-full overflow-y-auto p-4 md:p-6">
|
||||||
<div className="mx-auto max-w-5xl">
|
<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>}
|
{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">
|
<div className="overflow-x-auto rounded-xl border border-border bg-card">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
@@ -93,7 +97,15 @@ export const Pm2View = () => {
|
|||||||
{sorted.map((p) => (
|
{sorted.map((p) => (
|
||||||
<tr key={p.id} className="border-t border-border/60">
|
<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 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">
|
<td className="p-3">
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
<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)}`} />
|
<span className={`inline-block h-2 w-2 rounded-full ${statusColor(p.status)}`} />
|
||||||
|
|||||||
Reference in New Issue
Block a user