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