system-monitor: black log pane + live docker container logs

- Shared LogStream component (solid-black <pre> terminal pane) replaces Pm2Logs;
  pm2 and docker both drill into it.
- GET /api/system-monitor/docker/logs?id=<container>&lines=<n> — SSE of
  `docker logs -f` (combined stdout+stderr); id charset-validated + spawn arg.
- DockerView: container cards are now clickable → live logs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 10:42:02 +00:00
co-authored by Claude Opus 4.8
parent 67e6646bca
commit f3108f6d83
4 changed files with 154 additions and 5 deletions
@@ -279,3 +279,88 @@ systemMonitorRouter.get('/pm2/logs', (ctx) => {
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' },
});
});
// GET /docker/logs?id=<container>&lines=<n> — SSE stream of a container's live logs (docker logs -f,
// combined stdout+stderr). id validated to a docker id/name charset + passed as a spawn arg (no shell).
systemMonitorRouter.get('/docker/logs', (ctx) => {
const id = ctx.req.query('id') ?? '';
if (!/^[a-zA-Z0-9][\w.-]{0,127}$/.test(id)) return ctx.text('valid container id/name 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 {
/* gone */
}
try {
controller.close();
} catch {
/* closed */
}
};
try {
proc = Bun.spawn(['docker', 'logs', '-f', '--tail', String(lines), id], { stdout: 'pipe', stderr: 'pipe' });
} catch (err) {
send(`[error spawning docker logs: ${String(err)}]`);
controller.close();
return;
}
const pump = async (rs: ReadableStream<Uint8Array> | null) => {
if (!rs) return;
const reader = rs.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 {
/* ended */
}
};
// Containers log to both stdout and stderr; stream both.
void Promise.all([
pump(proc.stdout as ReadableStream<Uint8Array>),
pump(proc.stderr as ReadableStream<Uint8Array>),
]).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' },
});
});