diff --git a/src/servers/api/system-monitor/system-monitor.ts b/src/servers/api/system-monitor/system-monitor.ts index 007882a7..557e1909 100644 --- a/src/servers/api/system-monitor/system-monitor.ts +++ b/src/servers/api/system-monitor/system-monitor.ts @@ -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=&lines= — 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({ + 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 { + /* 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 | 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), + pump(proc.stderr as ReadableStream), + ]).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/DockerView.tsx b/src/workspaces/officerdev/src/apps/SystemMonitor/DockerView.tsx index 7d9cb8c2..873d1585 100644 --- a/src/workspaces/officerdev/src/apps/SystemMonitor/DockerView.tsx +++ b/src/workspaces/officerdev/src/apps/SystemMonitor/DockerView.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useClient } from 'hooks/useClient'; +import { LogStream } from './LogStream'; type Container = { id: string; name: string; image: string; state: string; status: string; ports: string }; @@ -10,6 +11,7 @@ export const DockerView = () => { const { get } = useClient(); const [containers, setContainers] = useState(null); const [error, setError] = useState(null); + const [selected, setSelected] = useState<{ id: string; name: string } | null>(null); const alive = useRef(true); useEffect(() => { @@ -31,14 +33,29 @@ export const DockerView = () => { }; }, []); + if (selected) + return ( + setSelected(null)} + /> + ); + return (
-
docker · {containers?.length ?? 0} running
+
docker · {containers?.length ?? 0} running · click a container for live logs
{error &&
{error}
}
{(containers ?? []).map((c) => ( -
+ ))} {containers && containers.length === 0 &&

no running containers

}
diff --git a/src/workspaces/officerdev/src/apps/SystemMonitor/LogStream.tsx b/src/workspaces/officerdev/src/apps/SystemMonitor/LogStream.tsx new file mode 100644 index 00000000..9ff2487e --- /dev/null +++ b/src/workspaces/officerdev/src/apps/SystemMonitor/LogStream.tsx @@ -0,0 +1,39 @@ +import { useEffect, useRef, useState } from 'react'; +import { useClient } from 'hooks/useClient'; +import { ChevronLeft } from 'lucide-react'; + +const MAX_LINES = 1200; + +// Generic live log viewer: SSE-tails `${streamUrl}&token=…` (server sends one `data:` frame per line), +// on a solid-black terminal-style pane. Used for both pm2 and docker logs. +export const LogStream = ({ streamUrl, title, backLabel, onBack }: { streamUrl: string; title: string; backLabel: string; onBack: () => void }) => { + const { token } = useClient(); + const [lines, setLines] = useState([]); + const scrollRef = useRef(null); + + useEffect(() => { + setLines([]); + const es = new EventSource(`${streamUrl}&token=${encodeURIComponent(token ?? '')}`); + es.onmessage = (ev) => setLines((prev) => [...prev.slice(-(MAX_LINES - 1)), ev.data]); + return () => es.close(); + }, [streamUrl, token]); + + useEffect(() => { + scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight); + }, [lines]); + + return ( +
+
+ + {title} + +
+
+        {lines.length === 0 ? connecting… : lines.join('\n')}
+      
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/SystemMonitor/Pm2View.tsx b/src/workspaces/officerdev/src/apps/SystemMonitor/Pm2View.tsx index b03d1472..db854b34 100644 --- a/src/workspaces/officerdev/src/apps/SystemMonitor/Pm2View.tsx +++ b/src/workspaces/officerdev/src/apps/SystemMonitor/Pm2View.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useClient } from 'hooks/useClient'; import { fmtBytes, fmtDuration } from './shared'; -import { Pm2Logs } from './Pm2Logs'; +import { LogStream } from './LogStream'; 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'; @@ -68,7 +68,15 @@ export const Pm2View = () => { return arr; }, [procs, sortKey, sortDir]); - if (selected) return setSelected(null)} />; + if (selected) + return ( + setSelected(null)} + /> + ); return (