From c6da792e4b787b2ad306c5868b806f4be3e34195 Mon Sep 17 00:00:00 2001 From: RGJorge Date: Fri, 1 May 2026 04:55:52 +0000 Subject: [PATCH] v0.0.9 --- src/client/App.tsx | 101 ++++++++++++++----------------- src/client/hooks/useDocker.ts | 3 +- src/client/index.css | 43 +++++++++---- src/client/nodes/ServiceNode.tsx | 42 ++++++------- src/server/index.ts | 80 ++++++++++++++++++++---- src/server/watcher.ts | 2 +- 6 files changed, 169 insertions(+), 102 deletions(-) diff --git a/src/client/App.tsx b/src/client/App.tsx index cbd7bad..5471e74 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -269,61 +269,52 @@ function Dashboard({ token }: { token: string }) { initialLayoutDone.current = true; } else { setNodes((prev) => { - const updated = prev.map((n) => { - const u = newNodes.find((nn) => nn.id === n.id); - if (!u) return null; - return { ...n, data: u.data }; - }).filter(Boolean) as Node[]; + const newNodeMap = new Map(newNodes.map((n) => [n.id, n])); + const prevNodeMap = new Map(prev.map((n) => [n.id, n])); - const existingIds = new Set(updated.map((n) => n.id)); - const brand = newNodes.filter((n) => !existingIds.has(n.id)); + // 1. Update existing nodes (keep position, update data) + 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; - - const hasSaved = brand.some((n) => savedPositions.current[n.id]); - if (hasSaved) { - let positioned = brand.map((n) => { - const saved = savedPositions.current[n.id]; - if (saved) return { ...n, position: saved }; - return n; - }); - positioned = positioned.map((n) => { - if (n.type !== "group") return n; - const kids = [...updated, ...positioned].filter((c) => c.parentId === n.id); - if (kids.length === 0) return n; - let maxRight = 0; - let maxBottom = 0; - for (const k of kids) { - 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]; + // 2. Resize groups to fit their children + for (let i = 0; i < result.length; i++) { + const n = result[i]; + if (n.type !== "group") continue; + const kids = result.filter((c) => c.parentId === n.id); + if (kids.length === 0) continue; + let maxRight = 0; + let maxBottom = 0; + for (const k of kids) { + 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); + result[i] = { ...n, style: { ...n.style, width: newW, height: newH } }; } - let maxRightX = 0; - for (const n of updated) { - if (n.type === "group") { - const w = (n.style?.width as number) || NODE_W + G_PAD * 3; - maxRightX = Math.max(maxRightX, n.position.x + w); + // 3. Recompute edges + const { edges: updatedEdges, activeHandles } = computeEdges(result, filteredConnections); + setEdges(updatedEdges); + for (const n of result) { + if (n.type === "service") { + (n.data as any).activeHandles = activeHandles.get(n.id) || []; } } - const newGroups = brand.filter((n) => n.type === "group"); - 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]; + return result; }); } }, [filteredServices, filteredConnections, statsVersion]); @@ -507,22 +498,24 @@ function Dashboard({ token }: { token: string }) { edgeTypes={edgeTypes} fitView fitViewOptions={{ padding: 0.3 }} - minZoom={0.2} - maxZoom={2.5} + minZoom={0.3} + maxZoom={1} panOnScroll={true} + translateExtent={[[-1000, -1000], [8000, 6000]]} proOptions={{ hideAttribution: true }} > - + { + if (n.type === "group") return "#1e293b"; const state = (n.data as any)?.state; - if (state === "running") return "#22c55e"; - if (state === "exited" || state === "dead") return "#ef4444"; - return "#f59e0b"; + if (state === "running") return "#22c55e80"; + if (state === "exited" || state === "dead") return "#ef444480"; + return "#f59e0b80"; }} style={{ background: "#0f172a" }} /> diff --git a/src/client/hooks/useDocker.ts b/src/client/hooks/useDocker.ts index b79575b..e71149f 100644 --- a/src/client/hooks/useDocker.ts +++ b/src/client/hooks/useDocker.ts @@ -90,7 +90,8 @@ export function useDocker(token = "") { break; case "connections": 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; }); break; diff --git a/src/client/index.css b/src/client/index.css index d1553c8..7d0c712 100644 --- a/src/client/index.css +++ b/src/client/index.css @@ -15,6 +15,7 @@ background-color: #0f172a !important; border: 1px solid #1e293b !important; border-radius: 8px !important; + overflow: hidden !important; } .react-flow__controls { @@ -27,6 +28,13 @@ background-color: #1e293b !important; color: #94a3b8 !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 { @@ -34,24 +42,35 @@ } /* Flash animations for Docker events */ -@keyframes flash-green { - 0%, 100% { box-shadow: 0 0 0 0 transparent; } - 50% { box-shadow: 0 0 20px 4px rgba(34, 197, 94, 0.6); } +@keyframes flash-border-green { + 0% { border-color: #22c55e; box-shadow: 0 0 12px 2px rgba(34, 197, 94, 0.4); } + 100% { border-color: rgba(51, 65, 85, 0.8); box-shadow: none; } } -@keyframes flash-red { - 0%, 100% { box-shadow: 0 0 0 0 transparent; } - 50% { box-shadow: 0 0 20px 4px rgba(239, 68, 68, 0.6); } +@keyframes flash-border-red { + 0% { border-color: #ef4444; box-shadow: 0 0 12px 2px rgba(239, 68, 68, 0.4); } + 100% { border-color: #ef4444; box-shadow: none; } } -@keyframes flash-yellow { - 0%, 100% { box-shadow: 0 0 0 0 transparent; } - 50% { box-shadow: 0 0 20px 4px rgba(245, 158, 11, 0.6); } +@keyframes flash-border-yellow { + 0% { border-color: #f59e0b; box-shadow: 0 0 12px 2px rgba(245, 158, 11, 0.4); } + 100% { border-color: rgba(51, 65, 85, 0.8); box-shadow: none; } } -.flash-start { animation: flash-green 0.6s ease-out; } -.flash-stop { animation: flash-red 0.6s ease-out; } -.flash-restart { animation: flash-yellow 0.6s ease-out 2; } +.flash-start { animation: flash-border-green 1.2s ease-out; } +.flash-stop { animation: flash-border-red 1.2s ease-out forwards; } +.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 */ @keyframes slideUp { diff --git a/src/client/nodes/ServiceNode.tsx b/src/client/nodes/ServiceNode.tsx index 92c4e6a..db5b84d 100644 --- a/src/client/nodes/ServiceNode.tsx +++ b/src/client/nodes/ServiceNode.tsx @@ -39,12 +39,12 @@ interface ServiceNodeData { [key: string]: unknown; } -const stateStyles: Record = { - running: { ring: "ring-emerald-500/50", dot: "bg-emerald-500", bg: "bg-emerald-500/10" }, - exited: { ring: "ring-red-500/50", dot: "bg-red-500", bg: "bg-red-500/10" }, - paused: { 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" }, - dead: { ring: "ring-red-500/50", dot: "bg-red-500", bg: "bg-red-500/10" }, +const stateStyles: Record = { + 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", border: "border-red-500/60" }, + 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", border: "border-amber-500/60" }, + 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 @@ -123,7 +123,7 @@ export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) { return (
`${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} transition-all duration-300 ${flashClass}`} style={particleGlow ? { @@ -153,28 +153,28 @@ export const ServiceNode = memo(function ServiceNode({ data }: NodeProps) { ))} - {/* Header: icon + name + status dot */} -
+ {/* Top section: icon left, name + image right */} +
- + +
+
+
+ {d.label} +
+
+
+ {d.image.startsWith("sha256:") ? `Sin Tag (${d.image.slice(7, 19)})` : d.image} +
- {d.label} -
-
- {/* Image */} -
{d.image}
- {/* Ports */} {d.ports?.length > 0 && ( -
+
{d.ports.map((p) => ( { - broadcast({ type: "docker_event", data: event }); -}); - -// ── Stats polling ── -let lastServicesHash = ""; -let lastConnectionsHash = ""; - -setInterval(async () => { +async function refreshServices() { try { const services = await discoverServices(ALL, PROJECTS); const connections = await discoverConnections(services); const stats = await pollStats(services); - // Only send services/connections if changed const svcHash = services.map((s) => `${s.uid}:${s.state}`).join("|"); if (svcHash !== lastServicesHash) { lastServicesHash = svcHash; @@ -166,12 +157,55 @@ setInterval(async () => { broadcast({ type: "connections", data: connections }); } - // Stats always change (cpu/mem fluctuate) broadcast({ type: "stats", data: stats }); } 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 | undefined; +let retryTimer: ReturnType | 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 ── const server = Bun.serve({ @@ -199,6 +233,16 @@ const server = Bun.serve({ if (flowsData.flows.length > 0) { 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) { @@ -222,6 +266,16 @@ const server = Bun.serve({ if (flowsData.flows.length > 0) { 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 { native.send(JSON.stringify({ type: "auth_error" })); native.close(); diff --git a/src/server/watcher.ts b/src/server/watcher.ts index 5ac21ee..4d14a79 100644 --- a/src/server/watcher.ts +++ b/src/server/watcher.ts @@ -60,7 +60,7 @@ export function watchDockerEvents(onEvent: (event: DockerEvent) => void) { if (event.Type !== "container") continue; 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 = event.Actor?.Attributes?.["com.docker.compose.service"] ||