This commit is contained in:
RGJorge
2026-05-01 04:55:52 +00:00
parent cb25260627
commit c6da792e4b
6 changed files with 169 additions and 102 deletions
+47 -54
View File
@@ -269,61 +269,52 @@ function Dashboard({ token }: { token: string }) {
initialLayoutDone.current = true; initialLayoutDone.current = true;
} else { } else {
setNodes((prev) => { setNodes((prev) => {
const updated = prev.map((n) => { const newNodeMap = new Map(newNodes.map((n) => [n.id, n]));
const u = newNodes.find((nn) => nn.id === n.id); const prevNodeMap = new Map(prev.map((n) => [n.id, n]));
if (!u) return null;
return { ...n, data: u.data };
}).filter(Boolean) as Node[];
const existingIds = new Set(updated.map((n) => n.id)); // 1. Update existing nodes (keep position, update data)
const brand = newNodes.filter((n) => !existingIds.has(n.id)); const result: Node[] = [];
for (const nn of newNodes) {
const existing = prevNodeMap.get(nn.id);
if (existing) {
// Keep position and style, update data
result.push({ ...existing, data: nn.data });
} else {
// New node — use saved position if available
const saved = savedPositions.current[nn.id];
result.push(saved ? { ...nn, position: saved } : nn);
}
}
// Nodes in prev but NOT in newNodes are simply dropped (they disappeared)
if (brand.length === 0) return updated; // 2. Resize groups to fit their children
for (let i = 0; i < result.length; i++) {
const hasSaved = brand.some((n) => savedPositions.current[n.id]); const n = result[i];
if (hasSaved) { if (n.type !== "group") continue;
let positioned = brand.map((n) => { const kids = result.filter((c) => c.parentId === n.id);
const saved = savedPositions.current[n.id]; if (kids.length === 0) continue;
if (saved) return { ...n, position: saved }; let maxRight = 0;
return n; let maxBottom = 0;
}); for (const k of kids) {
positioned = positioned.map((n) => { maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD);
if (n.type !== "group") return n; maxBottom = Math.max(maxBottom, k.position.y + NODE_H + G_PAD);
const kids = [...updated, ...positioned].filter((c) => c.parentId === n.id); }
if (kids.length === 0) return n; const minW = NODE_W + G_PAD * 3;
let maxRight = 0; const newW = Math.max(maxRight, minW);
let maxBottom = 0; const newH = Math.max(maxBottom, MIN_Y + NODE_H + G_PAD);
for (const k of kids) { result[i] = { ...n, style: { ...n.style, width: newW, height: newH } };
maxRight = Math.max(maxRight, k.position.x + NODE_W + G_PAD);
maxBottom = Math.max(maxBottom, k.position.y + NODE_H + G_PAD);
}
const minW = NODE_W + G_PAD * 3;
const newW = Math.max(maxRight, minW);
const newH = Math.max(maxBottom, MIN_Y + NODE_H + G_PAD);
return { ...n, style: { ...n.style, width: newW, height: newH } };
});
return [...updated, ...positioned];
} }
let maxRightX = 0; // 3. Recompute edges
for (const n of updated) { const { edges: updatedEdges, activeHandles } = computeEdges(result, filteredConnections);
if (n.type === "group") { setEdges(updatedEdges);
const w = (n.style?.width as number) || NODE_W + G_PAD * 3; for (const n of result) {
maxRightX = Math.max(maxRightX, n.position.x + w); if (n.type === "service") {
(n.data as any).activeHandles = activeHandles.get(n.id) || [];
} }
} }
const newGroups = brand.filter((n) => n.type === "group"); return result;
const offsetX = maxRightX > 0 ? maxRightX + 50 - (newGroups[0]?.position.x || 0) : 0;
const positioned = brand.map((n) => {
if (n.type === "group" && offsetX > 0) {
return { ...n, position: { x: n.position.x + offsetX, y: n.position.y } };
}
return n;
});
return [...updated, ...positioned];
}); });
} }
}, [filteredServices, filteredConnections, statsVersion]); }, [filteredServices, filteredConnections, statsVersion]);
@@ -507,22 +498,24 @@ function Dashboard({ token }: { token: string }) {
edgeTypes={edgeTypes} edgeTypes={edgeTypes}
fitView fitView
fitViewOptions={{ padding: 0.3 }} fitViewOptions={{ padding: 0.3 }}
minZoom={0.2} minZoom={0.3}
maxZoom={2.5} maxZoom={1}
panOnScroll={true} panOnScroll={true}
translateExtent={[[-1000, -1000], [8000, 6000]]}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
> >
<ParticleOverlay engine={engine} settings={flowSettings} onNodeHits={handleNodeHits} /> <ParticleOverlay engine={engine} settings={flowSettings} onNodeHits={handleNodeHits} />
<Background color="#1e293b" gap={24} size={1} /> <Background color="#374151" gap={30} size={2} />
<Controls position="bottom-left" /> <Controls position="bottom-left" />
<EdgeLegend /> <EdgeLegend />
<MiniMap <MiniMap
position="bottom-right" position="bottom-right"
nodeColor={(n) => { nodeColor={(n) => {
if (n.type === "group") return "#1e293b";
const state = (n.data as any)?.state; const state = (n.data as any)?.state;
if (state === "running") return "#22c55e"; if (state === "running") return "#22c55e80";
if (state === "exited" || state === "dead") return "#ef4444"; if (state === "exited" || state === "dead") return "#ef444480";
return "#f59e0b"; return "#f59e0b80";
}} }}
style={{ background: "#0f172a" }} style={{ background: "#0f172a" }}
/> />
+2 -1
View File
@@ -90,7 +90,8 @@ export function useDocker(token = "") {
break; break;
case "connections": case "connections":
setConnections((prev) => { setConnections((prev) => {
if (prev.length === msg.data.length) return prev; if (prev.length === msg.data.length &&
prev.every((c: any, i: number) => c.from === msg.data[i].from && c.to === msg.data[i].to)) return prev;
return msg.data; return msg.data;
}); });
break; break;
+31 -12
View File
@@ -15,6 +15,7 @@
background-color: #0f172a !important; background-color: #0f172a !important;
border: 1px solid #1e293b !important; border: 1px solid #1e293b !important;
border-radius: 8px !important; border-radius: 8px !important;
overflow: hidden !important;
} }
.react-flow__controls { .react-flow__controls {
@@ -27,6 +28,13 @@
background-color: #1e293b !important; background-color: #1e293b !important;
color: #94a3b8 !important; color: #94a3b8 !important;
border-bottom: 1px solid #334155 !important; border-bottom: 1px solid #334155 !important;
width: 32px !important;
height: 32px !important;
}
.react-flow__controls-button svg {
width: 14px !important;
height: 14px !important;
} }
.react-flow__controls-button:hover { .react-flow__controls-button:hover {
@@ -34,24 +42,35 @@
} }
/* Flash animations for Docker events */ /* Flash animations for Docker events */
@keyframes flash-green { @keyframes flash-border-green {
0%, 100% { box-shadow: 0 0 0 0 transparent; } 0% { border-color: #22c55e; box-shadow: 0 0 12px 2px rgba(34, 197, 94, 0.4); }
50% { box-shadow: 0 0 20px 4px rgba(34, 197, 94, 0.6); } 100% { border-color: rgba(51, 65, 85, 0.8); box-shadow: none; }
} }
@keyframes flash-red { @keyframes flash-border-red {
0%, 100% { box-shadow: 0 0 0 0 transparent; } 0% { border-color: #ef4444; box-shadow: 0 0 12px 2px rgba(239, 68, 68, 0.4); }
50% { box-shadow: 0 0 20px 4px rgba(239, 68, 68, 0.6); } 100% { border-color: #ef4444; box-shadow: none; }
} }
@keyframes flash-yellow { @keyframes flash-border-yellow {
0%, 100% { box-shadow: 0 0 0 0 transparent; } 0% { border-color: #f59e0b; box-shadow: 0 0 12px 2px rgba(245, 158, 11, 0.4); }
50% { box-shadow: 0 0 20px 4px rgba(245, 158, 11, 0.6); } 100% { border-color: rgba(51, 65, 85, 0.8); box-shadow: none; }
} }
.flash-start { animation: flash-green 0.6s ease-out; } .flash-start { animation: flash-border-green 1.2s ease-out; }
.flash-stop { animation: flash-red 0.6s ease-out; } .flash-stop { animation: flash-border-red 1.2s ease-out forwards; }
.flash-restart { animation: flash-yellow 0.6s ease-out 2; } .flash-restart { animation: flash-border-yellow 1.2s ease-out 2; }
/* Fade out animation for removed nodes */
@keyframes node-fade-out {
0% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(0.95); }
}
.node-removing {
animation: node-fade-out 0.8s ease-out forwards;
pointer-events: none;
}
/* Log panel slide-up */ /* Log panel slide-up */
@keyframes slideUp { @keyframes slideUp {
+21 -21
View File
@@ -39,12 +39,12 @@ interface ServiceNodeData {
[key: string]: unknown; [key: string]: unknown;
} }
const stateStyles: Record<string, { ring: string; dot: string; bg: string }> = { const stateStyles: Record<string, { ring: string; dot: string; bg: string; border: string }> = {
running: { ring: "ring-emerald-500/50", dot: "bg-emerald-500", bg: "bg-emerald-500/10" }, running: { ring: "ring-emerald-500/50", dot: "bg-emerald-500", bg: "bg-emerald-500/10", border: "border-slate-700/80" },
exited: { ring: "ring-red-500/50", dot: "bg-red-500", bg: "bg-red-500/10" }, exited: { ring: "ring-red-500/50", dot: "bg-red-500", bg: "bg-red-500/10", border: "border-red-500/60" },
paused: { ring: "ring-amber-500/50", dot: "bg-amber-500", bg: "bg-amber-500/10" }, paused: { ring: "ring-amber-500/50", dot: "bg-amber-500", bg: "bg-amber-500/10", border: "border-amber-500/60" },
restarting: { ring: "ring-amber-500/50", dot: "bg-amber-500", bg: "bg-amber-500/10" }, restarting: { ring: "ring-amber-500/50", dot: "bg-amber-500", bg: "bg-amber-500/10", border: "border-amber-500/60" },
dead: { ring: "ring-red-500/50", dot: "bg-red-500", bg: "bg-red-500/10" }, dead: { ring: "ring-red-500/50", dot: "bg-red-500", bg: "bg-red-500/10", border: "border-red-500/60" },
}; };
// Map image/name patterns to Lucide icons and colors // Map image/name patterns to Lucide icons and colors
@@ -123,7 +123,7 @@ export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) {
return ( return (
<div <div
title={`${d.label} (${d.state})\nImage: ${d.image}\nID: ${d.id || ""}\nPorts: ${d.ports?.map((p) => `${p.host}:${p.container}`).join(", ") || "none"}`} title={`${d.label} (${d.state})\nImage: ${d.image}\nID: ${d.id || ""}\nPorts: ${d.ports?.map((p) => `${p.host}:${p.container}`).join(", ") || "none"}`}
className={`relative rounded-xl border border-slate-700/80 ${s.bg} backdrop-blur-sm className={`relative rounded-xl border ${s.border} ${s.bg} backdrop-blur-sm
shadow-lg shadow-black/30 p-4 min-w-[220px] ring-2 ${particleGlow ? "" : s.ring} shadow-lg shadow-black/30 p-4 min-w-[220px] ring-2 ${particleGlow ? "" : s.ring}
transition-all duration-300 ${flashClass}`} transition-all duration-300 ${flashClass}`}
style={particleGlow ? { style={particleGlow ? {
@@ -153,28 +153,28 @@ export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) {
<Handle key={`rt${i}`} type="target" position={Position.Right} id={`right-${i}-target`} className={hdot(`right-${i}`)} style={{ top: o, transform: "translate(50%, -50%)" }} /> <Handle key={`rt${i}`} type="target" position={Position.Right} id={`right-${i}-target`} className={hdot(`right-${i}`)} style={{ top: o, transform: "translate(50%, -50%)" }} />
))} ))}
{/* Header: icon + name + status dot */} {/* Top section: icon left, name + image right */}
<div className="flex items-center gap-2.5 mb-2"> <div className="flex gap-3 mb-2">
<div <div
className="flex items-center justify-center w-8 h-8 rounded-lg" className="flex items-center justify-center w-9 h-9 rounded-lg shrink-0 self-center"
style={{ backgroundColor: `${iconColor}22` }} style={{ backgroundColor: `${iconColor}22` }}
> >
<Icon size={18} style={{ color: iconColor }} /> <Icon size={20} style={{ color: iconColor }} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-bold text-white text-sm truncate">{d.label}</span>
<div className={`w-2 h-2 rounded-full shrink-0 ${s.dot}`} />
</div>
<div className="text-xs text-slate-500 truncate mt-0.5">
{d.image.startsWith("sha256:") ? `Sin Tag (${d.image.slice(7, 19)})` : d.image}
</div>
</div> </div>
<span className="font-bold text-white text-sm truncate">{d.label}</span>
<div
className={`w-2 h-2 rounded-full shrink-0 ${s.dot}
${d.state === "running" ? "animate-pulse" : ""}`}
/>
<div className="flex-1" />
</div> </div>
{/* Image */}
<div className="text-xs text-slate-500 truncate mb-2 pl-10">{d.image}</div>
{/* Ports */} {/* Ports */}
{d.ports?.length > 0 && ( {d.ports?.length > 0 && (
<div className="flex gap-1.5 flex-wrap mb-2 pl-10"> <div className="flex gap-1.5 flex-wrap mb-2">
{d.ports.map((p) => ( {d.ports.map((p) => (
<span <span
key={`${p.host}:${p.container}`} key={`${p.host}:${p.container}`}
+67 -13
View File
@@ -139,21 +139,12 @@ function cleanupLogStream(ws: WebSocket) {
} }
// ── Docker events ── // ── Docker events ──
watchDockerEvents((event) => { async function refreshServices() {
broadcast({ type: "docker_event", data: event });
});
// ── Stats polling ──
let lastServicesHash = "";
let lastConnectionsHash = "";
setInterval(async () => {
try { try {
const services = await discoverServices(ALL, PROJECTS); const services = await discoverServices(ALL, PROJECTS);
const connections = await discoverConnections(services); const connections = await discoverConnections(services);
const stats = await pollStats(services); const stats = await pollStats(services);
// Only send services/connections if changed
const svcHash = services.map((s) => `${s.uid}:${s.state}`).join("|"); const svcHash = services.map((s) => `${s.uid}:${s.state}`).join("|");
if (svcHash !== lastServicesHash) { if (svcHash !== lastServicesHash) {
lastServicesHash = svcHash; lastServicesHash = svcHash;
@@ -166,12 +157,55 @@ setInterval(async () => {
broadcast({ type: "connections", data: connections }); broadcast({ type: "connections", data: connections });
} }
// Stats always change (cpu/mem fluctuate)
broadcast({ type: "stats", data: stats }); broadcast({ type: "stats", data: stats });
} catch (err) { } catch (err) {
console.error("Poll error:", err); console.error("Refresh error:", err);
} }
}, POLL_INTERVAL_MS); }
// Quick refresh — services + connections, no stats (fast)
async function quickRefresh() {
try {
const services = await discoverServices(ALL, PROJECTS);
const svcHash = services.map((s) => `${s.uid}:${s.state}`).join("|");
if (svcHash !== lastServicesHash) {
lastServicesHash = svcHash;
broadcast({ type: "services", data: services });
// Also refresh connections when services change
const connections = await discoverConnections(services);
const connHash = connections.map((c) => `${c.from}:${c.to}`).join("|");
if (connHash !== lastConnectionsHash) {
lastConnectionsHash = connHash;
broadcast({ type: "connections", data: connections });
}
}
} catch {}
}
// Debounced refresh for Docker events
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
function scheduleRefresh() {
clearTimeout(refreshTimer);
clearTimeout(retryTimer);
// First check at 1.5s, retry at 3.5s to catch stragglers (e.g. slow destroy)
refreshTimer = setTimeout(() => {
quickRefresh();
retryTimer = setTimeout(quickRefresh, 2000);
}, 1500);
}
watchDockerEvents((event) => {
broadcast({ type: "docker_event", data: event });
scheduleRefresh();
});
// ── Stats polling ──
let lastServicesHash = "";
let lastConnectionsHash = "";
setInterval(refreshServices, POLL_INTERVAL_MS);
// ── Start ── // ── Start ──
const server = Bun.serve({ const server = Bun.serve({
@@ -199,6 +233,16 @@ const server = Bun.serve({
if (flowsData.flows.length > 0) { if (flowsData.flows.length > 0) {
try { native.send(JSON.stringify({ type: "flows", data: flowsData })); } catch {} try { native.send(JSON.stringify({ type: "flows", data: flowsData })); } catch {}
} }
// Send current services/connections/stats
discoverServices(ALL, PROJECTS).then(async (services) => {
const connections = await discoverConnections(services);
const stats = await pollStats(services);
try {
native.send(JSON.stringify({ type: "services", data: services }));
native.send(JSON.stringify({ type: "connections", data: connections }));
native.send(JSON.stringify({ type: "stats", data: stats }));
} catch {}
}).catch(() => {});
} }
}, },
close(ws) { close(ws) {
@@ -222,6 +266,16 @@ const server = Bun.serve({
if (flowsData.flows.length > 0) { if (flowsData.flows.length > 0) {
native.send(JSON.stringify({ type: "flows", data: flowsData })); native.send(JSON.stringify({ type: "flows", data: flowsData }));
} }
// Send current services/connections/stats immediately
discoverServices(ALL, PROJECTS).then(async (services) => {
const connections = await discoverConnections(services);
const stats = await pollStats(services);
try {
native.send(JSON.stringify({ type: "services", data: services }));
native.send(JSON.stringify({ type: "connections", data: connections }));
native.send(JSON.stringify({ type: "stats", data: stats }));
} catch {}
}).catch(() => {});
} else { } else {
native.send(JSON.stringify({ type: "auth_error" })); native.send(JSON.stringify({ type: "auth_error" }));
native.close(); native.close();
+1 -1
View File
@@ -60,7 +60,7 @@ export function watchDockerEvents(onEvent: (event: DockerEvent) => void) {
if (event.Type !== "container") continue; if (event.Type !== "container") continue;
const action = event.Action?.split(":")[0]; // "health_status: healthy" → "health_status" const action = event.Action?.split(":")[0]; // "health_status: healthy" → "health_status"
if (!["start", "stop", "die", "restart", "health_status"].includes(action)) continue; if (!["start", "stop", "die", "restart", "destroy", "create", "health_status"].includes(action)) continue;
const svcName = const svcName =
event.Actor?.Attributes?.["com.docker.compose.service"] || event.Actor?.Attributes?.["com.docker.compose.service"] ||