mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.20
This commit is contained in:
+11
-9
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 } };
|
||||
|
||||
Reference in New Issue
Block a user