From 67e6646bcac8f02f7eb36d786be22f4069dcd2cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 27 Jul 2026 10:32:53 +0000 Subject: [PATCH] =?UTF-8?q?system-monitor:=20live=20pm2=20log=20streaming?= =?UTF-8?q?=20=E2=80=94=20click=20a=20process=20name=20to=20tail=20its=20l?= =?UTF-8?q?ogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/system-monitor/pm2/logs?id=&lines= — SSE that spawns `pm2 logs --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 --- .../api/system-monitor/system-monitor.ts | 80 +++++++++++++++++++ .../src/apps/SystemMonitor/Pm2Logs.tsx | 42 ++++++++++ .../src/apps/SystemMonitor/Pm2View.tsx | 16 +++- 3 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 src/workspaces/officerdev/src/apps/SystemMonitor/Pm2Logs.tsx diff --git a/src/servers/api/system-monitor/system-monitor.ts b/src/servers/api/system-monitor/system-monitor.ts index 9fe03291..007882a7 100644 --- a/src/servers/api/system-monitor/system-monitor.ts +++ b/src/servers/api/system-monitor/system-monitor.ts @@ -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=&lines= — SSE stream of a pm2 process's live logs (combined out+err via +// `pm2 logs --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({ + start(controller) { + let proc: ReturnType | 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).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' }, + }); +}); diff --git a/src/workspaces/officerdev/src/apps/SystemMonitor/Pm2Logs.tsx b/src/workspaces/officerdev/src/apps/SystemMonitor/Pm2Logs.tsx new file mode 100644 index 00000000..1ec3607e --- /dev/null +++ b/src/workspaces/officerdev/src/apps/SystemMonitor/Pm2Logs.tsx @@ -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([]); + const scrollRef = useRef(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 ( +
+
+ + {name} + +
+
+ {lines.length === 0 ? ( + connecting… + ) : ( + lines.map((l, i) =>
{l}
) + )} +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/SystemMonitor/Pm2View.tsx b/src/workspaces/officerdev/src/apps/SystemMonitor/Pm2View.tsx index 7192ca52..b03d1472 100644 --- a/src/workspaces/officerdev/src/apps/SystemMonitor/Pm2View.tsx +++ b/src/workspaces/officerdev/src/apps/SystemMonitor/Pm2View.tsx @@ -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(null); const [sortKey, setSortKey] = useState('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 setSelected(null)} />; + return (
-
pm2 · {procs?.length ?? 0} processes
+
pm2 · {procs?.length ?? 0} processes · click a name for live logs
{error &&
{error}
}
@@ -93,7 +97,15 @@ export const Pm2View = () => { {sorted.map((p) => ( - +
{p.id}{p.name} + +