This commit is contained in:
RGJorge
2026-05-04 02:43:11 +00:00
parent 36080e466b
commit d18a459a57
6 changed files with 178 additions and 112 deletions
+11 -9
View File
@@ -75,7 +75,7 @@ function Dashboard({ token }: { token: string }) {
const onPositions = useCallback((pos: Record<string, { x: number; y: number }>) => {
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<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const initialLayoutDone = useRef(false);
@@ -647,20 +647,20 @@ function Dashboard({ token }: { token: string }) {
onClose={() => setContextMenu(null)}
onAction={(action) => {
const svc = contextMenu.service;
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`/api/containers/${svc.id}/${action}`, { method: "POST", headers })
.then((r) => {
if (r.ok) {
// 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" || action === "rebuild" ? 5000 : 0;
const minDuration = action === "restart" ? 2000 : action === "rebuild" ? 3000 : 0;
setProcessing(svc.uid, expectedState, minDuration);
}
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`/api/containers/${svc.id}/${action}`, { method: "POST", headers })
.then((r) => {
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}
/>
)}
</div>
+55 -32
View File
@@ -19,10 +19,39 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
const [logLines, setLogLines] = useState<LogLine[]>([]);
// Processing state: uid → { expected state, start time, min duration before clearing }
const processingRef = useRef<Map<string, { expected: Service["state"]; startedAt: number; minDuration: number }>>(new Map());
const lastRawServicesRef = useRef<Service[]>([]);
const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>(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<string, string> = {};
@@ -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 };
}
+36 -17
View File
@@ -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<string, string>;
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<LogLine[]>([]);
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<string, string> = {};
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<string>();
@@ -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";
+20 -2
View File
@@ -245,11 +245,15 @@ export function streamContainerLogs(
const container = docker.getContainer(id);
let stream: NodeJS.ReadableStream | null = null;
let destroyed = false;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
const destroyStream = (s: unknown) => {
if (s && typeof (s as any).destroy === "function") (s as any).destroy();
};
function connect() {
if (destroyed) return;
container.logs({
stdout: true,
stderr: true,
@@ -294,13 +298,27 @@ export function streamContainerLogs(
});
}
});
}).catch((err) => {
console.error(`Failed to stream logs for ${id}:`, err);
// 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);
});
}
connect();
return {
destroy() {
destroyed = true;
clearTimeout(reconnectTimer);
if (stream) {
destroyStream(stream);
stream = null;
+9 -6
View File
@@ -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;
proc.exited.then(async (exitCode) => {
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
return c.json({ error: stderr || `Rebuild failed with exit code ${exitCode}` }, 500);
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<typeof setTimeout> | undefined;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let lateRetryTimer: ReturnType<typeof setTimeout> | 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);
}
+2 -1
View File
@@ -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 } };