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' },
});
});