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:
+12
-10
@@ -75,7 +75,7 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
const onPositions = useCallback((pos: Record<string, { x: number; y: number }>) => {
|
const onPositions = useCallback((pos: Record<string, { x: number; y: number }>) => {
|
||||||
savedPositions.current = pos;
|
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 [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
const initialLayoutDone = useRef(false);
|
const initialLayoutDone = useRef(false);
|
||||||
@@ -647,20 +647,20 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
onClose={() => setContextMenu(null)}
|
onClose={() => setContextMenu(null)}
|
||||||
onAction={(action) => {
|
onAction={(action) => {
|
||||||
const svc = contextMenu.service;
|
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<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
fetch(`/api/containers/${svc.id}/${action}`, { method: "POST", headers })
|
fetch(`/api/containers/${svc.id}/${action}`, { method: "POST", headers })
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (r.ok) {
|
if (!r.ok) clearProcessing(svc.uid);
|
||||||
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);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => clearProcessing(svc.uid));
|
||||||
}}
|
}}
|
||||||
onOpenLogs={() => {
|
onOpenLogs={() => {
|
||||||
const svc = contextMenu.service;
|
const svc = contextMenu.service;
|
||||||
@@ -703,6 +703,7 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
closing={panelClosing}
|
closing={panelClosing}
|
||||||
onClose={closeDetail}
|
onClose={closeDetail}
|
||||||
onAction={setProcessing}
|
onAction={setProcessing}
|
||||||
|
clearProcessing={clearProcessing}
|
||||||
sendMessage={sendMessage}
|
sendMessage={sendMessage}
|
||||||
clearLogLines={clearLogLines}
|
clearLogLines={clearLogLines}
|
||||||
connections={filteredConnections}
|
connections={filteredConnections}
|
||||||
@@ -711,6 +712,7 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
initialLogsFullscreen={openLogsFullscreen}
|
initialLogsFullscreen={openLogsFullscreen}
|
||||||
envFiles={envFiles}
|
envFiles={envFiles}
|
||||||
onEnvFileChange={handleEnvFileChange}
|
onEnvFileChange={handleEnvFileChange}
|
||||||
|
events={events}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,10 +19,39 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
|
|||||||
const [logLines, setLogLines] = useState<LogLine[]>([]);
|
const [logLines, setLogLines] = useState<LogLine[]>([]);
|
||||||
// Processing state: uid → { expected state, start time, min duration before clearing }
|
// 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 processingRef = useRef<Map<string, { expected: Service["state"]; startedAt: number; minDuration: number }>>(new Map());
|
||||||
|
const lastRawServicesRef = useRef<Service[]>([]);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
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
|
// Single init call: services + connections + positions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
@@ -80,37 +109,8 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
|
|||||||
|
|
||||||
switch (msg.type as WSMessage["type"]) {
|
switch (msg.type as WSMessage["type"]) {
|
||||||
case "services": {
|
case "services": {
|
||||||
const processing = processingRef.current;
|
lastRawServicesRef.current = msg.data as Service[];
|
||||||
let incoming = msg.data as Service[];
|
const incoming = applyProcessing(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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
|
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -142,6 +142,11 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
|
|||||||
return next.length > 2000 ? next.slice(-1500) : next;
|
return next.length > 2000 ? next.slice(-1500) : next;
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case "action_error": {
|
||||||
|
processingRef.current.delete(msg.data.uid);
|
||||||
|
setServices((prev) => [...prev]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to parse WS message:", 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 });
|
processingRef.current.set(uid, { expected: expectedState, startedAt, minDuration });
|
||||||
actionTimestamps.current.set(uid, Math.floor(startedAt / 1000));
|
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));
|
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 => {
|
const getLogsSince = useCallback((uid: string): number | undefined => {
|
||||||
return actionTimestamps.current.get(uid);
|
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 { 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 { 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";
|
type Tab = "info" | "config" | "env" | "stats";
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ interface DetailPanelProps {
|
|||||||
closing?: boolean;
|
closing?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onAction: (serviceUid: string, expectedState: Service["state"], minDuration?: number) => void;
|
onAction: (serviceUid: string, expectedState: Service["state"], minDuration?: number) => void;
|
||||||
|
clearProcessing: (uid: string) => void;
|
||||||
sendMessage: (msg: WSMessage) => void;
|
sendMessage: (msg: WSMessage) => void;
|
||||||
clearLogLines: () => void;
|
clearLogLines: () => void;
|
||||||
connections: Connection[];
|
connections: Connection[];
|
||||||
@@ -44,9 +45,10 @@ interface DetailPanelProps {
|
|||||||
initialLogsFullscreen?: boolean;
|
initialLogsFullscreen?: boolean;
|
||||||
envFiles: Record<string, string>;
|
envFiles: Record<string, string>;
|
||||||
onEnvFileChange: (composeFile: string, envFile: string | null) => void;
|
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 [initialLogs, setInitialLogs] = useState<LogLine[]>([]);
|
||||||
const [autoScroll, setAutoScroll] = useState(true);
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -101,6 +103,17 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
|||||||
setActionResult(null);
|
setActionResult(null);
|
||||||
setConfirmAction(null);
|
setConfirmAction(null);
|
||||||
actionTimestampRef.current = Math.floor(Date.now() / 1000);
|
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 {
|
try {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
@@ -108,29 +121,21 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setActionResult({ type: "success", message: `${action} successful` });
|
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") {
|
if (action === "remove") {
|
||||||
setTimeout(() => handleClose(), 1000);
|
setTimeout(() => handleClose(), 1000);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
clearProcessing(service.uid);
|
||||||
setActionResult({ type: "error", message: data.error || `Failed to ${action}` });
|
setActionResult({ type: "error", message: data.error || `Failed to ${action}` });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
clearProcessing(service.uid);
|
||||||
setActionResult({ type: "error", message: `Failed to ${action}` });
|
setActionResult({ type: "error", message: `Failed to ${action}` });
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
setTimeout(() => setActionResult(null), 3000);
|
setTimeout(() => setActionResult(null), 3000);
|
||||||
}
|
}
|
||||||
}, [service.id, service.uid, token, onAction]);
|
}, [service.id, service.uid, token, onAction, clearProcessing]);
|
||||||
|
|
||||||
const runExec = useCallback(async () => {
|
const runExec = useCallback(async () => {
|
||||||
if (!execCmd.trim()) return;
|
if (!execCmd.trim()) return;
|
||||||
@@ -254,10 +259,23 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
|||||||
setAutoScroll((prev) => prev === atBottom ? prev : atBottom);
|
setAutoScroll((prev) => prev === atBottom ? prev : atBottom);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const allLines = useMemo(
|
// Docker events as special log lines
|
||||||
() => [...initialLogs, ...logLines],
|
const eventLogLines = useMemo(() => {
|
||||||
[initialLogs, logLines]
|
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 connectedSvcs = useMemo(() => {
|
||||||
const connectedUids = new Set<string>();
|
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;
|
const WARN_PATTERN = /\b(warn|warning)\b/i;
|
||||||
|
|
||||||
function logLineColor(l: LogLine): string {
|
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 (ERROR_PATTERN.test(l.line)) return "text-red-400";
|
||||||
if (WARN_PATTERN.test(l.line)) return "text-yellow-400";
|
if (WARN_PATTERN.test(l.line)) return "text-yellow-400";
|
||||||
return "text-slate-400";
|
return "text-slate-400";
|
||||||
|
|||||||
+61
-43
@@ -245,62 +245,80 @@ export function streamContainerLogs(
|
|||||||
const container = docker.getContainer(id);
|
const container = docker.getContainer(id);
|
||||||
let stream: NodeJS.ReadableStream | null = null;
|
let stream: NodeJS.ReadableStream | null = null;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
const destroyStream = (s: unknown) => {
|
const destroyStream = (s: unknown) => {
|
||||||
if (s && typeof (s as any).destroy === "function") (s as any).destroy();
|
if (s && typeof (s as any).destroy === "function") (s as any).destroy();
|
||||||
};
|
};
|
||||||
|
|
||||||
container.logs({
|
function connect() {
|
||||||
stdout: true,
|
if (destroyed) return;
|
||||||
stderr: true,
|
|
||||||
follow: true,
|
|
||||||
since: Math.floor(Date.now() / 1000),
|
|
||||||
timestamps: true,
|
|
||||||
}).then((s) => {
|
|
||||||
stream = s as unknown as NodeJS.ReadableStream;
|
|
||||||
|
|
||||||
if (destroyed) {
|
container.logs({
|
||||||
destroyStream(stream);
|
stdout: true,
|
||||||
stream = null;
|
stderr: true,
|
||||||
return;
|
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
|
if (destroyed) {
|
||||||
let buffer = Buffer.alloc(0);
|
destroyStream(stream);
|
||||||
|
stream = null;
|
||||||
stream.on("data", (chunk: Buffer) => {
|
return;
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
return {
|
||||||
destroy() {
|
destroy() {
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
if (stream) {
|
if (stream) {
|
||||||
destroyStream(stream);
|
destroyStream(stream);
|
||||||
stream = null;
|
stream = null;
|
||||||
|
|||||||
+12
-9
@@ -167,19 +167,26 @@ app.post("/api/containers/:id/rebuild", async (c) => {
|
|||||||
const info = await container.inspect();
|
const info = await container.inspect();
|
||||||
const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"];
|
const composeFile = info.Config?.Labels?.["com.docker.compose.project.config_files"];
|
||||||
const serviceName = info.Config?.Labels?.["com.docker.compose.service"];
|
const serviceName = info.Config?.Labels?.["com.docker.compose.service"];
|
||||||
|
const project = info.Config?.Labels?.["com.docker.compose.project"] || "standalone";
|
||||||
if (!composeFile || !serviceName) {
|
if (!composeFile || !serviceName) {
|
||||||
return c.json({ error: "Not a Compose service — rebuild requires docker-compose" }, 400);
|
return c.json({ error: "Not a Compose service — rebuild requires docker-compose" }, 400);
|
||||||
}
|
}
|
||||||
|
const uid = `${project}/${serviceName}`;
|
||||||
const envArgs = findEnvFileArgs(composeFile);
|
const envArgs = findEnvFileArgs(composeFile);
|
||||||
|
// Run rebuild in background — respond immediately
|
||||||
const proc = Bun.spawn(["docker", "compose", "-f", composeFile, ...envArgs, "up", "--build", "-d", serviceName], {
|
const proc = Bun.spawn(["docker", "compose", "-f", composeFile, ...envArgs, "up", "--build", "-d", serviceName], {
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
});
|
});
|
||||||
const exitCode = await proc.exited;
|
proc.exited.then(async (exitCode) => {
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
const stderr = await new Response(proc.stderr).text();
|
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 });
|
return c.json({ ok: true });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return c.json({ error: err?.message || "Failed to rebuild container" }, 500);
|
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
|
// Debounced refresh for Docker events
|
||||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let lateRetryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
function scheduleRefresh() {
|
function scheduleRefresh() {
|
||||||
// Invalidate hash so next refresh always broadcasts (restart: same final state but clients need the update)
|
// Invalidate hash so next refresh always broadcasts (restart: same final state but clients need the update)
|
||||||
lastServicesHash = "";
|
lastServicesHash = "";
|
||||||
clearTimeout(refreshTimer);
|
clearTimeout(refreshTimer);
|
||||||
clearTimeout(retryTimer);
|
clearTimeout(retryTimer);
|
||||||
clearTimeout(lateRetryTimer);
|
|
||||||
refreshTimer = setTimeout(() => {
|
refreshTimer = setTimeout(() => {
|
||||||
refreshServices();
|
refreshServices();
|
||||||
retryTimer = setTimeout(refreshServices, 1500);
|
retryTimer = setTimeout(refreshServices, 1500);
|
||||||
// Late retry for restart/rebuild: client ignores first 5s, so re-broadcast after that
|
|
||||||
lateRetryTimer = setTimeout(() => { lastServicesHash = ""; refreshServices(); }, 6000);
|
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -57,4 +57,5 @@ export type WSMessage =
|
|||||||
| { type: "docker_event"; data: DockerEvent }
|
| { type: "docker_event"; data: DockerEvent }
|
||||||
| { type: "subscribe_logs"; container: string }
|
| { type: "subscribe_logs"; container: string }
|
||||||
| { type: "unsubscribe_logs" }
|
| { 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