diff --git a/src/client/App.tsx b/src/client/App.tsx index 33a27d2..c1b3392 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -75,7 +75,7 @@ function Dashboard({ token }: { token: string }) { const onPositions = useCallback((pos: Record) => { savedPositions.current = pos; }, []); - const { services, connections, stats, events, connected, logLines, sendMessage, clearLogLines, setProcessing, getLogsSince } = useDocker(token, statsStore, onPositions); + const { services, connections, stats, events, connected, logLines, sendMessage, clearLogLines, setProcessing, clearProcessing, getLogsSince } = useDocker(token, statsStore, onPositions); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const initialLayoutDone = useRef(false); @@ -647,20 +647,20 @@ function Dashboard({ token }: { token: string }) { onClose={() => setContextMenu(null)} onAction={(action) => { const svc = contextMenu.service; + // Optimistic processing — set BEFORE fetch + const expectedState: Service["state"] = + action === "stop" || action === "remove" ? "exited" : + action === "start" || action === "restart" || action === "rebuild" ? "running" : + svc.state; + const minDuration = action === "restart" ? 2000 : action === "rebuild" ? 3000 : 0; + setProcessing(svc.uid, expectedState, minDuration); const headers: Record = {}; if (token) headers["Authorization"] = `Bearer ${token}`; fetch(`/api/containers/${svc.id}/${action}`, { method: "POST", headers }) .then((r) => { - if (r.ok) { - const expectedState: Service["state"] = - action === "stop" || action === "remove" ? "exited" : - action === "start" || action === "restart" || action === "rebuild" ? "running" : - svc.state; - const minDuration = action === "restart" || action === "rebuild" ? 5000 : 0; - setProcessing(svc.uid, expectedState, minDuration); - } + if (!r.ok) clearProcessing(svc.uid); }) - .catch(() => {}); + .catch(() => clearProcessing(svc.uid)); }} onOpenLogs={() => { const svc = contextMenu.service; @@ -703,6 +703,7 @@ function Dashboard({ token }: { token: string }) { closing={panelClosing} onClose={closeDetail} onAction={setProcessing} + clearProcessing={clearProcessing} sendMessage={sendMessage} clearLogLines={clearLogLines} connections={filteredConnections} @@ -711,6 +712,7 @@ function Dashboard({ token }: { token: string }) { initialLogsFullscreen={openLogsFullscreen} envFiles={envFiles} onEnvFileChange={handleEnvFileChange} + events={events} /> )} diff --git a/src/client/hooks/useDocker.ts b/src/client/hooks/useDocker.ts index 4b68781..1d27741 100644 --- a/src/client/hooks/useDocker.ts +++ b/src/client/hooks/useDocker.ts @@ -19,10 +19,39 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po const [logLines, setLogLines] = useState([]); // Processing state: uid → { expected state, start time, min duration before clearing } const processingRef = useRef>(new Map()); + const lastRawServicesRef = useRef([]); const [connected, setConnected] = useState(false); const wsRef = useRef(null); const reconnectTimer = useRef>(undefined); + // Apply processing overlay to raw services data + const applyProcessing = useCallback((raw: Service[]): Service[] => { + const processing = processingRef.current; + if (processing.size === 0) return raw; + const now = Date.now(); + return raw.map((s: Service) => { + const entry = processing.get(s.uid); + if (!entry) return s; + const elapsed = now - entry.startedAt; + if (elapsed < entry.minDuration) { + return { ...s, state: "processing" as any, _processingStartedAt: entry.startedAt } as any; + } + if (s.state === entry.expected) { + processing.delete(s.uid); + return s; + } + if (s.state === "crashed" && entry.expected === "running") { + processing.delete(s.uid); + return s; + } + if (now - entry.startedAt > 15000) { + processing.delete(s.uid); + return s; + } + return { ...s, state: "processing" as any, _processingStartedAt: entry.startedAt } as any; + }); + }, []); + // Single init call: services + connections + positions useEffect(() => { const headers: Record = {}; @@ -80,37 +109,8 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po switch (msg.type as WSMessage["type"]) { case "services": { - const processing = processingRef.current; - let incoming = msg.data as Service[]; - if (processing.size > 0) { - const now = Date.now(); - incoming = incoming.map((s: Service) => { - const entry = processing.get(s.uid); - if (!entry) return s; - // Don't clear processing until minDuration has passed (restart/rebuild need time for stop→start cycle) - const elapsed = now - entry.startedAt; - if (elapsed < entry.minDuration) { - return { ...s, state: "processing" as any, _processingStartedAt: entry.startedAt } as any; - } - // Server confirms expected state → clear processing - if (s.state === entry.expected) { - processing.delete(s.uid); - return s; - } - // Container crashed while we expected "running" → clear processing, show crashed - if (s.state === "crashed" && entry.expected === "running") { - processing.delete(s.uid); - return s; - } - // Timeout after 15s → give up, show real state - if (now - entry.startedAt > 15000) { - processing.delete(s.uid); - return s; - } - // State doesn't match expected → keep in processing, ignore stale data - return { ...s, state: "processing" as any, _processingStartedAt: entry.startedAt } as any; - }); - } + lastRawServicesRef.current = msg.data as Service[]; + const incoming = applyProcessing(msg.data as Service[]); setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming); break; } @@ -142,6 +142,11 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po return next.length > 2000 ? next.slice(-1500) : next; }); break; + case "action_error": { + processingRef.current.delete(msg.data.uid); + setServices((prev) => [...prev]); + break; + } } } catch (err) { console.error("Failed to parse WS message:", err); @@ -191,11 +196,29 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po processingRef.current.set(uid, { expected: expectedState, startedAt, minDuration }); actionTimestamps.current.set(uid, Math.floor(startedAt / 1000)); setServices((prev) => prev.map((s) => s.uid === uid ? { ...s, state: "processing" as any, _processingStartedAt: startedAt } as any : s)); + // Re-evaluate every 1s after minDuration until processing clears. + // Handles the case where the server stops broadcasting (hash unchanged). + if (minDuration > 0) { + const interval = setInterval(() => { + if (!processingRef.current.has(uid)) { clearInterval(interval); return; } + const elapsed = Date.now() - startedAt; + if (elapsed < minDuration) return; + const incoming = applyProcessing(lastRawServicesRef.current); + setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming); + // applyProcessing deletes the entry when resolved or timed out (15s) + if (!processingRef.current.has(uid)) clearInterval(interval); + }, 1000); + } + }, [applyProcessing]); + + const clearProcessing = useCallback((uid: string) => { + processingRef.current.delete(uid); + setServices((prev) => [...prev]); }, []); const getLogsSince = useCallback((uid: string): number | undefined => { return actionTimestamps.current.get(uid); }, []); - return { services, connections, stats: statsRef.current, events, connected, logLines, sendMessage, clearLogLines, setProcessing, getLogsSince }; + return { services, connections, stats: statsRef.current, events, connected, logLines, sendMessage, clearLogLines, setProcessing, clearProcessing, getLogsSince }; } diff --git a/src/client/panels/DetailPanel.tsx b/src/client/panels/DetailPanel.tsx index 76c7ed4..867ba13 100644 --- a/src/client/panels/DetailPanel.tsx +++ b/src/client/panels/DetailPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, useCallback, useMemo, startTransition } from "react"; import { X, Pause, Play, Square, RotateCw, Hammer, Trash2, Terminal, Network, Globe, Info as InfoIcon, Activity, Variable, Settings, ChevronUp, ChevronDown, Eye, EyeOff, Copy, Check, Loader2, AlertTriangle, Maximize2, ExternalLink, Pencil, HelpCircle } from "lucide-react"; -import type { Service, Stats, LogLine, WSMessage, Connection } from "../../shared/types"; +import type { Service, Stats, LogLine, WSMessage, Connection, DockerEvent } from "../../shared/types"; type Tab = "info" | "config" | "env" | "stats"; @@ -36,6 +36,7 @@ interface DetailPanelProps { closing?: boolean; onClose: () => void; onAction: (serviceUid: string, expectedState: Service["state"], minDuration?: number) => void; + clearProcessing: (uid: string) => void; sendMessage: (msg: WSMessage) => void; clearLogLines: () => void; connections: Connection[]; @@ -44,9 +45,10 @@ interface DetailPanelProps { initialLogsFullscreen?: boolean; envFiles: Record; onEnvFileChange: (composeFile: string, envFile: string | null) => void; + events: DockerEvent[]; } -export function DetailPanel({ service, stats, logLines, token, closing, onClose, onAction, sendMessage, clearLogLines, connections, services, getLogsSince, initialLogsFullscreen, envFiles, onEnvFileChange }: DetailPanelProps) { +export function DetailPanel({ service, stats, logLines, token, closing, onClose, onAction, clearProcessing, sendMessage, clearLogLines, connections, services, getLogsSince, initialLogsFullscreen, envFiles, onEnvFileChange, events }: DetailPanelProps) { const [initialLogs, setInitialLogs] = useState([]); const [autoScroll, setAutoScroll] = useState(true); const [loading, setLoading] = useState(true); @@ -101,6 +103,17 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, setActionResult(null); setConfirmAction(null); actionTimestampRef.current = Math.floor(Date.now() / 1000); + + // Optimistic processing — set BEFORE fetch + const expectedState: Service["state"] = + action === "stop" || action === "remove" ? "exited" : + action === "start" || action === "restart" || action === "rebuild" ? "running" : + service.state; + const minDuration = action === "restart" ? 2000 : action === "rebuild" ? 3000 : 0; + onAction(service.uid, expectedState, minDuration); + setInitialLogs([]); + clearLogLines(); + try { const headers: Record = {}; if (token) headers["Authorization"] = `Bearer ${token}`; @@ -108,29 +121,21 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, const data = await res.json(); if (res.ok) { setActionResult({ type: "success", message: `${action} successful` }); - setInitialLogs([]); - clearLogLines(); - // Set processing — pass expected state so we wait for server to confirm - const expectedState: Service["state"] = - action === "stop" || action === "remove" ? "exited" : - action === "start" || action === "restart" || action === "rebuild" ? "running" : - service.state; - // restart/rebuild go through stop→start cycle, need minDuration to avoid clearing on intermediate states - const minDuration = action === "restart" || action === "rebuild" ? 5000 : 0; - onAction(service.uid, expectedState, minDuration); if (action === "remove") { setTimeout(() => handleClose(), 1000); } } else { + clearProcessing(service.uid); setActionResult({ type: "error", message: data.error || `Failed to ${action}` }); } } catch { + clearProcessing(service.uid); setActionResult({ type: "error", message: `Failed to ${action}` }); } finally { setActionLoading(null); setTimeout(() => setActionResult(null), 3000); } - }, [service.id, service.uid, token, onAction]); + }, [service.id, service.uid, token, onAction, clearProcessing]); const runExec = useCallback(async () => { if (!execCmd.trim()) return; @@ -254,10 +259,23 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, setAutoScroll((prev) => prev === atBottom ? prev : atBottom); }, []); - const allLines = useMemo( - () => [...initialLogs, ...logLines], - [initialLogs, logLines] - ); + // Docker events as special log lines + const eventLogLines = useMemo(() => { + return events + .filter((e) => e.service === service.uid) + .map((e): LogLine => ({ + container: service.id, + line: `[DOCKER] Container ${e.action}`, + timestamp: new Date(e.time * 1000).toISOString(), + stream: "stderr", + })); + }, [events, service.uid, service.id]); + + const allLines = useMemo(() => { + const combined = [...initialLogs, ...logLines, ...eventLogLines]; + combined.sort((a, b) => (a.timestamp || "").localeCompare(b.timestamp || "")); + return combined; + }, [initialLogs, logLines, eventLogLines]); const connectedSvcs = useMemo(() => { const connectedUids = new Set(); @@ -1039,6 +1057,7 @@ const ERROR_PATTERN = /\b(error|fatal|critical|exception|traceback|panic|failed| const WARN_PATTERN = /\b(warn|warning)\b/i; function logLineColor(l: LogLine): string { + if (l.line.startsWith("[DOCKER]")) return "text-cyan-400 font-semibold"; if (ERROR_PATTERN.test(l.line)) return "text-red-400"; if (WARN_PATTERN.test(l.line)) return "text-yellow-400"; return "text-slate-400"; diff --git a/src/server/docker.ts b/src/server/docker.ts index 047c4aa..59ed634 100644 --- a/src/server/docker.ts +++ b/src/server/docker.ts @@ -245,62 +245,80 @@ export function streamContainerLogs( const container = docker.getContainer(id); let stream: NodeJS.ReadableStream | null = null; let destroyed = false; + let reconnectTimer: ReturnType | undefined; const destroyStream = (s: unknown) => { if (s && typeof (s as any).destroy === "function") (s as any).destroy(); }; - container.logs({ - stdout: true, - stderr: true, - follow: true, - since: Math.floor(Date.now() / 1000), - timestamps: true, - }).then((s) => { - stream = s as unknown as NodeJS.ReadableStream; + function connect() { + if (destroyed) return; - if (destroyed) { - destroyStream(stream); - stream = null; - return; - } + container.logs({ + stdout: true, + stderr: true, + follow: true, + since: Math.floor(Date.now() / 1000), + timestamps: true, + }).then((s) => { + stream = s as unknown as NodeJS.ReadableStream; - // Docker multiplexed stream parsing for follow mode - let buffer = Buffer.alloc(0); - - stream.on("data", (chunk: Buffer) => { - if (destroyed) return; - buffer = Buffer.concat([buffer, chunk]); - - while (buffer.length >= 8) { - const streamType = buffer[0]; - const size = buffer.readUInt32BE(4); - if (buffer.length < 8 + size) break; - - const payload = buffer.slice(8, 8 + size).toString("utf-8").trimEnd(); - buffer = buffer.slice(8 + size); - - if (!payload) continue; - - const spaceIdx = payload.indexOf(" "); - const timestamp = spaceIdx > 0 ? payload.slice(0, spaceIdx) : ""; - const line = spaceIdx > 0 ? payload.slice(spaceIdx + 1) : payload; - - onLine({ - container: id, - line, - timestamp, - stream: streamType === 2 ? "stderr" : "stdout", - }); + if (destroyed) { + destroyStream(stream); + stream = null; + return; } + + // Docker multiplexed stream parsing for follow mode + let buffer = Buffer.alloc(0); + + stream.on("data", (chunk: Buffer) => { + if (destroyed) return; + buffer = Buffer.concat([buffer, chunk]); + + while (buffer.length >= 8) { + const streamType = buffer[0]; + const size = buffer.readUInt32BE(4); + if (buffer.length < 8 + size) break; + + const payload = buffer.slice(8, 8 + size).toString("utf-8").trimEnd(); + buffer = buffer.slice(8 + size); + + if (!payload) continue; + + const spaceIdx = payload.indexOf(" "); + const timestamp = spaceIdx > 0 ? payload.slice(0, spaceIdx) : ""; + const line = spaceIdx > 0 ? payload.slice(spaceIdx + 1) : payload; + + onLine({ + container: id, + line, + timestamp, + stream: streamType === 2 ? "stderr" : "stdout", + }); + } + }); + + // Auto-reconnect when stream closes (container stop/restart) + stream.on("end", () => { + stream = null; + if (!destroyed) reconnectTimer = setTimeout(connect, 2000); + }); + stream.on("error", () => { + stream = null; + if (!destroyed) reconnectTimer = setTimeout(connect, 2000); + }); + }).catch(() => { + if (!destroyed) reconnectTimer = setTimeout(connect, 2000); }); - }).catch((err) => { - console.error(`Failed to stream logs for ${id}:`, err); - }); + } + + connect(); return { destroy() { destroyed = true; + clearTimeout(reconnectTimer); if (stream) { destroyStream(stream); stream = null; diff --git a/src/server/index.ts b/src/server/index.ts index 6fc2dc5..deec9d6 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -167,19 +167,26 @@ app.post("/api/containers/:id/rebuild", async (c) => { const info = await container.inspect(); const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"]; const serviceName = info.Config?.Labels?.["com.docker.compose.service"]; + const project = info.Config?.Labels?.["com.docker.compose.project"] || "standalone"; if (!composeFile || !serviceName) { return c.json({ error: "Not a Compose service — rebuild requires docker-compose" }, 400); } + const uid = `${project}/${serviceName}`; const envArgs = findEnvFileArgs(composeFile); + // Run rebuild in background — respond immediately const proc = Bun.spawn(["docker", "compose", "-f", composeFile, ...envArgs, "up", "--build", "-d", serviceName], { stdout: "pipe", stderr: "pipe", }); - const exitCode = await proc.exited; - if (exitCode !== 0) { - const stderr = await new Response(proc.stderr).text(); - return c.json({ error: stderr || `Rebuild failed with exit code ${exitCode}` }, 500); - } + proc.exited.then(async (exitCode) => { + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + broadcast({ type: "action_error", data: { uid, action: "rebuild", error: stderr || `Rebuild failed with exit code ${exitCode}` } }); + } + scheduleRefresh(); + }).catch((err) => { + broadcast({ type: "action_error", data: { uid, action: "rebuild", error: err?.message || "Rebuild failed" } }); + }); return c.json({ ok: true }); } catch (err: any) { return c.json({ error: err?.message || "Failed to rebuild container" }, 500); @@ -446,18 +453,14 @@ async function refreshStats(services: Service[]) { // Debounced refresh for Docker events let refreshTimer: ReturnType | undefined; let retryTimer: ReturnType | undefined; -let lateRetryTimer: ReturnType | undefined; function scheduleRefresh() { // Invalidate hash so next refresh always broadcasts (restart: same final state but clients need the update) lastServicesHash = ""; clearTimeout(refreshTimer); clearTimeout(retryTimer); - clearTimeout(lateRetryTimer); refreshTimer = setTimeout(() => { refreshServices(); retryTimer = setTimeout(refreshServices, 1500); - // Late retry for restart/rebuild: client ignores first 5s, so re-broadcast after that - lateRetryTimer = setTimeout(() => { lastServicesHash = ""; refreshServices(); }, 6000); }, 500); } diff --git a/src/shared/types.ts b/src/shared/types.ts index 157d538..f03a708 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -57,4 +57,5 @@ export type WSMessage = | { type: "docker_event"; data: DockerEvent } | { type: "subscribe_logs"; container: string } | { type: "unsubscribe_logs" } - | { type: "log_line"; data: LogLine }; + | { type: "log_line"; data: LogLine } + | { type: "action_error"; data: { uid: string; action: string; error: string } };