diff --git a/client/src/components/ClusterTopology.tsx b/client/src/components/ClusterTopology.tsx new file mode 100644 index 0000000..99d7be6 --- /dev/null +++ b/client/src/components/ClusterTopology.tsx @@ -0,0 +1,551 @@ +/** + * ClusterTopology — Animated SVG network visualization for the dashboard. + * + * Shows real swarm nodes (manager / workers), services, agents, + * overlay-network links with animated "data packets", and live status pulses. + * + * Data: 100 % real from tRPC — nodes.list, nodes.services, agents.list + */ +import { useEffect, useMemo, useRef, useState } from "react"; +import { motion } from "framer-motion"; +import { trpc } from "@/lib/trpc"; +import { Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; + +/* ─── colour tokens (match index.css oklch values) ─────────────────────── */ +const C = { + cyan: "oklch(0.82 0.15 195)", + green: "oklch(0.82 0.2 155)", + amber: "oklch(0.82 0.16 80)", + red: "oklch(0.7 0.22 20)", + muted: "oklch(0.4 0.02 260)", + surface: "oklch(0.16 0.02 260)", + deep: "oklch(0.12 0.02 260)", + panel: "oklch(0.19 0.02 260)", + text: "oklch(0.92 0.01 260)", + textDim: "oklch(0.6 0.02 260)", +}; + +/* ─── types for the layout engine ──────────────────────────────────────── */ +interface LayoutNode { + id: string; + kind: "manager" | "worker" | "service" | "agent" | "overlay"; + label: string; + sub: string; + x: number; + y: number; + r: number; + color: string; + glowColor: string; + status: string; + extra?: Record; +} +interface LayoutEdge { + id: string; + from: string; + to: string; + color: string; + dashed: boolean; + label?: string; + animated: boolean; +} + +/* ─── helpers ──────────────────────────────────────────────────────────── */ + +function memLabel(mb: number) { + return mb > 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${mb} MB`; +} + +/* ─── main component ───────────────────────────────────────────────────── */ +export default function ClusterTopology() { + const svgRef = useRef(null); + const [hoveredNode, setHoveredNode] = useState(null); + const [tick, setTick] = useState(0); + + /* Live data */ + const nodesQ = trpc.nodes.list.useQuery(undefined, { refetchInterval: 15_000 }); + const servicesQ = trpc.nodes.services.useQuery(undefined, { refetchInterval: 30_000 }); + const agentsQ = trpc.agents.list.useQuery(undefined, { refetchInterval: 30_000 }); + + /* Animation tick for data-flow particles */ + useEffect(() => { + const id = setInterval(() => setTick((t) => t + 1), 50); + return () => clearInterval(id); + }, []); + + /* ── Build layout ─────────────────────────────────────────────────── */ + const { nodes: lnodes, edges: ledges } = useMemo(() => { + const nodes: LayoutNode[] = []; + const edges: LayoutEdge[] = []; + const W = 900; + const H = 460; + const CX = W / 2; + const CY = H / 2 - 10; + + const swarmNodes = nodesQ.data?.nodes ?? []; + const services = servicesQ.data?.services ?? []; + const agents = (agentsQ.data ?? []).filter((a: any) => a.isActive); + + if (swarmNodes.length === 0) return { nodes, edges }; + + /* Separate managers and workers */ + const managers = swarmNodes.filter((n: any) => n.role === "manager"); + const workers = swarmNodes.filter((n: any) => n.role !== "manager"); + + /* — Overlay network hub (centre) — */ + nodes.push({ + id: "__overlay__", + kind: "overlay", + label: "goclaw-net", + sub: "overlay network", + x: CX, + y: CY, + r: 36, + color: C.cyan, + glowColor: C.cyan, + status: "active", + }); + + /* — Manager(s) — top section, spread horizontally — */ + const managerY = 60; + managers.forEach((sn: any, i: number) => { + const spread = managers.length > 1 ? (i - (managers.length - 1) / 2) * 140 : 0; + const nodeId = `node_${sn.id}`; + nodes.push({ + id: nodeId, + kind: "manager", + label: sn.hostname, + sub: `${sn.ip} · Docker ${sn.dockerVersion}`, + x: CX + spread, + y: managerY, + r: 32, + color: C.green, + glowColor: C.green, + status: sn.availability ?? sn.state, + extra: { + role: "manager", + cpu: `${sn.cpuCores} cores`, + mem: memLabel(sn.memTotalMB), + leader: sn.isLeader ? "★ Leader" : "", + }, + }); + edges.push({ + id: `edge_overlay_${sn.id}`, + from: "__overlay__", + to: nodeId, + color: C.green, + dashed: false, + animated: true, + }); + }); + + /* — Workers — positioned to the left and right of the hub — */ + const workerXSpacing = 160; + const workerBaseY = CY; + workers.forEach((sn: any, i: number) => { + const side = i % 2 === 0 ? -1 : 1; + const tier = Math.floor(i / 2); + const nodeId = `node_${sn.id}`; + nodes.push({ + id: nodeId, + kind: "worker", + label: sn.hostname, + sub: `${sn.ip} · Docker ${sn.dockerVersion}`, + x: CX + side * (workerXSpacing + tier * 100), + y: workerBaseY + tier * 50, + r: 26, + color: C.cyan, + glowColor: C.cyan, + status: sn.availability ?? sn.state, + extra: { + role: "worker", + cpu: `${sn.cpuCores} cores`, + mem: memLabel(sn.memTotalMB), + }, + }); + edges.push({ + id: `edge_overlay_${sn.id}`, + from: "__overlay__", + to: nodeId, + color: C.cyan, + dashed: false, + animated: true, + }); + }); + + /* — Services — positioned to the right of the manager — */ + const managerNodeId = managers.length > 0 + ? `node_${managers[0].id}` + : nodes[1]?.id; + + if (managerNodeId) { + const mNode = nodes.find((n) => n.id === managerNodeId); + const svcBaseX = (mNode?.x ?? CX) + 120; + const svcBaseY = (mNode?.y ?? managerY) - 10; + services.forEach((svc: any, i: number) => { + const svcId = `svc_${svc.id}`; + nodes.push({ + id: svcId, + kind: "service", + label: svc.name, + sub: `${svc.mode} · ${svc.runningTasks}/${svc.desiredReplicas} replicas`, + x: svcBaseX + i * 60, + y: svcBaseY + (i % 2) * 30, + r: 16, + color: C.amber, + glowColor: C.amber, + status: svc.runningTasks > 0 ? "running" : "stopped", + }); + edges.push({ + id: `edge_svc_${svc.id}`, + from: managerNodeId, + to: svcId, + color: C.amber, + dashed: true, + label: "service", + animated: svc.runningTasks > 0, + }); + }); + } + + /* — Agents — spread across a wide arc below the hub — */ + const agentCY = H - 80; + const totalAgents = agents.length; + const agentSpacing = Math.min(110, (W - 160) / Math.max(totalAgents, 1)); + const agentStartX = CX - ((totalAgents - 1) * agentSpacing) / 2; + + agents.forEach((ag: any, i: number) => { + const agId = `agent_${ag.id}`; + const isOrch = ag.isOrchestrator; + const yJitter = (i % 2) * 20; + nodes.push({ + id: agId, + kind: "agent", + label: ag.name, + sub: `${ag.model} · ${ag.role}`, + x: agentStartX + i * agentSpacing, + y: agentCY + yJitter, + r: isOrch ? 22 : 16, + color: isOrch ? C.green : C.cyan, + glowColor: isOrch ? C.green : C.cyan, + status: ag.isActive ? "active" : "idle", + extra: { + orchestrator: isOrch ? "Yes" : "", + model: ag.model, + role: ag.role, + }, + }); + edges.push({ + id: `edge_agent_${ag.id}`, + from: "__overlay__", + to: agId, + color: isOrch ? C.green : C.textDim, + dashed: true, + animated: ag.isActive, + }); + }); + + return { nodes, edges }; + }, [nodesQ.data, servicesQ.data, agentsQ.data]); + + const isLoading = nodesQ.isLoading; + + /* ── SVG dimensions ──────────────────────────────────────────────── */ + const W = 900; + const H = 460; + + /* ── tooltip ─────────────────────────────────────────────────────── */ + const hNode = lnodes.find((n) => n.id === hoveredNode); + + return ( +
+ {isLoading && ( +
+ + Loading topology… +
+ )} + + + {/* ── defs ──────────────────────────────────────────────── */} + + {/* Glow filter */} + + + + + + + + + + + + + + + + {/* Animated dash gradient */} + + + + + + + {/* Grid pattern */} + + + + + {/* Radial backgrounds */} + + + + + + + {/* ── Background ───────────────────────────────────────── */} + + + + {/* Hub radial glow */} + + + {/* ── Edges ────────────────────────────────────────────── */} + {ledges.map((edge) => { + const from = lnodes.find((n) => n.id === edge.from); + const to = lnodes.find((n) => n.id === edge.to); + if (!from || !to) return null; + + const dx = to.x - from.x; + const dy = to.y - from.y; + const dist = Math.sqrt(dx * dx + dy * dy); + const ux = dx / dist; + const uy = dy / dist; + const x1 = from.x + ux * from.r; + const y1 = from.y + uy * from.r; + const x2 = to.x - ux * to.r; + const y2 = to.y - uy * to.r; + + const isHovered = hoveredNode === edge.from || hoveredNode === edge.to; + + return ( + + {/* Main line */} + + {/* Animated data-flow particle */} + {edge.animated && ( + + + + )} + {/* Second particle (offset) for busier connections */} + {edge.animated && !edge.dashed && ( + + + + )} + + ); + })} + + {/* ── Nodes ────────────────────────────────────────────── */} + {lnodes.map((node) => { + const isHovered = hoveredNode === node.id; + const scale = isHovered ? 1.12 : 1; + + return ( + setHoveredNode(node.id)} + onMouseLeave={() => setHoveredNode(null)} + > + {/* Pulse ring */} + {(node.status === "active" || node.status === "running") && ( + + + + + )} + + {/* Outer glow ring */} + + + {/* Main body */} + + + {/* Icon / text inside */} + {node.kind === "overlay" && ( + <> + + ⬡ + + + {node.label} + + + )} + {node.kind === "manager" && ( + <> + + 👑 + + + {node.label} + + + )} + {node.kind === "worker" && ( + <> + + ⚙ + + + {node.label} + + + )} + {node.kind === "service" && ( + <> + + ◆ + + + {node.label.length > 12 ? node.label.slice(0, 12) + "…" : node.label} + + + )} + {node.kind === "agent" && ( + <> + 16 ? "10" : "8"} fill={node.color} dy="-1"> + 🤖 + + 16 ? "10" : "8"}> + {node.label.length > 14 ? node.label.slice(0, 14) + "…" : node.label} + + + )} + + {/* Status dot */} + + + ); + })} + + {/* ── Legend (bottom-left) ──────────────────────────────── */} + + Topology Legend + {[ + { icon: "👑", label: "Manager", color: C.green, y: 16 }, + { icon: "⚙", label: "Worker", color: C.cyan, y: 28 }, + { icon: "🤖", label: "Agent", color: C.cyan, y: 40 }, + { icon: "◆", label: "Service", color: C.amber, y: 52 }, + { icon: "⬡", label: "Overlay", color: C.cyan, y: 64 }, + ].map((it) => ( + + {it.icon} + {it.label} + + ))} + + + {/* ── Stats (bottom-right) ─────────────────────────────── */} + + Cluster Stats + + Nodes: {nodesQ.data?.nodes?.length ?? 0} + {" · "}Services: {servicesQ.data?.services?.length ?? 0} + + + Agents: {(agentsQ.data ?? []).filter((a: any) => a.isActive).length} + {" · "}Swarm: {nodesQ.data?.swarmActive ? "active" : "off"} + + + + + {/* ── Hover tooltip (HTML overlay) ───────────────────────────── */} + {hNode && ( + +
+
+ {hNode.label} + + {hNode.status.toUpperCase()} + +
+
{hNode.sub}
+ {hNode.extra && Object.entries(hNode.extra).filter(([, v]) => v).map(([k, v]) => ( +
+ {k}: + {v} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index 1e2dab3..c3f7c03 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -25,9 +25,9 @@ import { } from "lucide-react"; import { motion } from "framer-motion"; import { trpc } from "@/lib/trpc"; +import ClusterTopology from "@/components/ClusterTopology"; const HERO_BG = "https://d2xsxph8kpxj0f.cloudfront.net/97147719/ZEGAT83geRq9CNvryykaQv/hero-bg-Si4yCvZwFbZMP4XaHUueFi.webp"; -const SWARM_IMG = "https://d2xsxph8kpxj0f.cloudfront.net/97147719/ZEGAT83geRq9CNvryykaQv/swarm-cluster-jkxdea5N7sXTSZfbAbKCfs.webp"; function getStatusColor(status: string) { switch (status) { @@ -478,36 +478,23 @@ export default function Dashboard() { - {/* Cluster visualization */} + {/* Cluster Topology — animated interactive visualization */} - + Cluster Topology + + + live + {stats && ( + · Uptime: {stats.uptime} + )} + - -
- Swarm Cluster Topology -
-
-
- Overlay Network: goclaw-net - {stats && ( - · Uptime: {stats.uptime} - )} -
-
- Manager - Worker - Drain -
-
-
+ +