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) });
|
||||
}
|
||||
});
|
||||
|
||||
// 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' },
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user