From f3108f6d83e67892c4e1833e1af6ed109d39d1ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 27 Jul 2026 10:42:02 +0000 Subject: [PATCH] system-monitor: black log pane + live docker container logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shared LogStream component (solid-black
 terminal pane) replaces Pm2Logs;
  pm2 and docker both drill into it.
- GET /api/system-monitor/docker/logs?id=&lines= — 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 
---
 .../api/system-monitor/system-monitor.ts      | 85 +++++++++++++++++++
 .../src/apps/SystemMonitor/DockerView.tsx     | 23 ++++-
 .../src/apps/SystemMonitor/LogStream.tsx      | 39 +++++++++
 .../src/apps/SystemMonitor/Pm2View.tsx        | 12 ++-
 4 files changed, 154 insertions(+), 5 deletions(-)
 create mode 100644 src/workspaces/officerdev/src/apps/SystemMonitor/LogStream.tsx

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 (