feat(dashboard): add animated Cluster Topology SVG visualization

- New ClusterTopology component: interactive SVG network diagram showing
  real-time cluster state from tRPC (nodes.list, nodes.services, agents.list)
- Manager nodes (crown icon) at top, overlay hub (goclaw-net) at center,
  worker nodes on sides, services attached to manager, agents along bottom arc
- Animated data-flow particles on edges (green for overlay, amber for services,
  dashed gray for agent connections)
- Pulse rings on active/running nodes
- Hover tooltips with detailed info (IP, Docker version, CPU, memory, role)
- Grid background, glow filters, color-coded legend and live stats
- Auto-refreshes every 15-30s matching dashboard refresh intervals
- Replaces static SWARM_IMG placeholder in Dashboard.tsx
- Live indicator shows 'live' pulse and uptime in card header
This commit is contained in:
bboxwtf
2026-03-22 11:14:08 +00:00
parent c1e8774009
commit 0f38bb5a43
2 changed files with 563 additions and 25 deletions

View File

@@ -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<string, string>;
}
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<SVGSVGElement>(null);
const [hoveredNode, setHoveredNode] = useState<string | null>(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 (
<div className="relative w-full" style={{ aspectRatio: `${W}/${H}`, maxHeight: 480 }}>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center z-20 bg-card/80 rounded-md">
<Loader2 className="w-6 h-6 text-primary animate-spin mr-2" />
<span className="font-mono text-xs text-muted-foreground">Loading topology</span>
</div>
)}
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
className="w-full h-full"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
{/* ── defs ──────────────────────────────────────────────── */}
<defs>
{/* Glow filter */}
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="6" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="glowSoft" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Animated dash gradient */}
<linearGradient id="gradCyan" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={C.cyan} stopOpacity={0.1} />
<stop offset="50%" stopColor={C.cyan} stopOpacity={0.7} />
<stop offset="100%" stopColor={C.cyan} stopOpacity={0.1} />
</linearGradient>
{/* Grid pattern */}
<pattern id="gridPat" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke={C.muted} strokeWidth="0.3" opacity="0.25" />
</pattern>
{/* Radial backgrounds */}
<radialGradient id="hubGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor={C.cyan} stopOpacity={0.15} />
<stop offset="100%" stopColor={C.cyan} stopOpacity={0} />
</radialGradient>
</defs>
{/* ── Background ───────────────────────────────────────── */}
<rect width={W} height={H} fill={C.deep} rx="8" />
<rect width={W} height={H} fill="url(#gridPat)" rx="8" />
{/* Hub radial glow */}
<circle cx={W / 2} cy={H / 2} r={120} fill="url(#hubGlow)" />
{/* ── 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 (
<g key={edge.id}>
{/* Main line */}
<line
x1={x1} y1={y1} x2={x2} y2={y2}
stroke={edge.color}
strokeWidth={isHovered ? 2 : 1}
strokeOpacity={isHovered ? 0.8 : 0.3}
strokeDasharray={edge.dashed ? "6 4" : undefined}
/>
{/* Animated data-flow particle */}
{edge.animated && (
<circle r={isHovered ? 3 : 2} fill={edge.color} opacity={0.9} filter="url(#glowSoft)">
<animateMotion
dur={`${2 + Math.random() * 1.5}s`}
repeatCount="indefinite"
path={`M${x1},${y1} L${x2},${y2}`}
/>
</circle>
)}
{/* Second particle (offset) for busier connections */}
{edge.animated && !edge.dashed && (
<circle r={1.5} fill={edge.color} opacity={0.5}>
<animateMotion
dur={`${3 + Math.random()}s`}
repeatCount="indefinite"
begin={`${1 + Math.random()}s`}
path={`M${x2},${y2} L${x1},${y1}`}
/>
</circle>
)}
</g>
);
})}
{/* ── Nodes ────────────────────────────────────────────── */}
{lnodes.map((node) => {
const isHovered = hoveredNode === node.id;
const scale = isHovered ? 1.12 : 1;
return (
<g
key={node.id}
transform={`translate(${node.x}, ${node.y})`}
style={{ cursor: "pointer" }}
onMouseEnter={() => setHoveredNode(node.id)}
onMouseLeave={() => setHoveredNode(null)}
>
{/* Pulse ring */}
{(node.status === "active" || node.status === "running") && (
<circle r={node.r + 8} fill="none" stroke={node.color} strokeWidth="1" opacity="0.4">
<animate attributeName="r" values={`${node.r + 4};${node.r + 14};${node.r + 4}`} dur="3s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.5;0.1;0.5" dur="3s" repeatCount="indefinite" />
</circle>
)}
{/* Outer glow ring */}
<circle
r={node.r * scale}
fill="none"
stroke={node.glowColor}
strokeWidth={isHovered ? 2.5 : 1.5}
opacity={isHovered ? 0.8 : 0.5}
filter="url(#glowSoft)"
/>
{/* Main body */}
<circle
r={(node.r - 2) * scale}
fill={C.panel}
stroke={node.color}
strokeWidth={isHovered ? 2 : 1}
opacity={0.95}
/>
{/* Icon / text inside */}
{node.kind === "overlay" && (
<>
<text textAnchor="middle" dominantBaseline="central" fontSize="10" fill={C.cyan} fontWeight="bold" dy="-4">
</text>
<text textAnchor="middle" dominantBaseline="central" fontSize="7" fill={C.text} dy="8" fontWeight="500">
{node.label}
</text>
</>
)}
{node.kind === "manager" && (
<>
<text textAnchor="middle" dominantBaseline="central" fontSize="14" fill={C.green} dy="-3">
👑
</text>
<text textAnchor="middle" dominantBaseline="central" fontSize="7" fill={C.text} dy="12" fontWeight="600">
{node.label}
</text>
</>
)}
{node.kind === "worker" && (
<>
<text textAnchor="middle" dominantBaseline="central" fontSize="12" fill={C.cyan} dy="-2">
</text>
<text textAnchor="middle" dominantBaseline="central" fontSize="7" fill={C.text} dy="10" fontWeight="500">
{node.label}
</text>
</>
)}
{node.kind === "service" && (
<>
<text textAnchor="middle" dominantBaseline="central" fontSize="9" fill={C.amber} dy="-1">
</text>
<text textAnchor="middle" dominantBaseline="central" fontSize="5.5" fill={C.textDim} dy="8">
{node.label.length > 12 ? node.label.slice(0, 12) + "…" : node.label}
</text>
</>
)}
{node.kind === "agent" && (
<>
<text textAnchor="middle" dominantBaseline="central" fontSize={node.r > 16 ? "10" : "8"} fill={node.color} dy="-1">
🤖
</text>
<text textAnchor="middle" dominantBaseline="central" fontSize="5" fill={C.textDim} dy={node.r > 16 ? "10" : "8"}>
{node.label.length > 14 ? node.label.slice(0, 14) + "…" : node.label}
</text>
</>
)}
{/* Status dot */}
<circle
cx={node.r * 0.7 * scale}
cy={-node.r * 0.7 * scale}
r={3}
fill={
node.status === "active" || node.status === "running"
? C.green
: node.status === "drain" || node.status === "stopped"
? C.amber
: C.red
}
stroke={C.deep}
strokeWidth={1.5}
/>
</g>
);
})}
{/* ── Legend (bottom-left) ──────────────────────────────── */}
<g transform={`translate(16, ${H - 74})`} fontSize="8" fill={C.textDim}>
<text fontWeight="600" fontSize="9" fill={C.text}>Topology Legend</text>
{[
{ 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) => (
<g key={it.label} transform={`translate(0, ${it.y})`}>
<text fontSize="9" dy="1">{it.icon}</text>
<text x="16" fill={it.color} fontWeight="500">{it.label}</text>
</g>
))}
</g>
{/* ── Stats (bottom-right) ─────────────────────────────── */}
<g transform={`translate(${W - 160}, ${H - 46})`} fontSize="8" fill={C.textDim}>
<text fontWeight="600" fontSize="9" fill={C.text}>Cluster Stats</text>
<text y="14">
Nodes: <tspan fill={C.green} fontWeight="600">{nodesQ.data?.nodes?.length ?? 0}</tspan>
{" · "}Services: <tspan fill={C.amber} fontWeight="600">{servicesQ.data?.services?.length ?? 0}</tspan>
</text>
<text y="26">
Agents: <tspan fill={C.cyan} fontWeight="600">{(agentsQ.data ?? []).filter((a: any) => a.isActive).length}</tspan>
{" · "}Swarm: <tspan fill={nodesQ.data?.swarmActive ? C.green : C.red} fontWeight="600">{nodesQ.data?.swarmActive ? "active" : "off"}</tspan>
</text>
</g>
</svg>
{/* ── Hover tooltip (HTML overlay) ───────────────────────────── */}
{hNode && (
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute z-30 pointer-events-none"
style={{
left: `${(hNode.x / W) * 100}%`,
top: `${(hNode.y / H) * 100 - 2}%`,
transform: "translate(-50%, -110%)",
}}
>
<div className="bg-card/95 border border-border/60 rounded-lg px-3 py-2 shadow-xl backdrop-blur-sm min-w-[160px]">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-semibold text-foreground">{hNode.label}</span>
<Badge variant="outline" className={`text-[8px] font-mono px-1 py-0 ${
hNode.status === "active" || hNode.status === "running"
? "bg-neon-green/15 text-neon-green border-neon-green/30"
: "bg-neon-amber/15 text-neon-amber border-neon-amber/30"
}`}>
{hNode.status.toUpperCase()}
</Badge>
</div>
<div className="text-[10px] font-mono text-muted-foreground">{hNode.sub}</div>
{hNode.extra && Object.entries(hNode.extra).filter(([, v]) => v).map(([k, v]) => (
<div key={k} className="text-[10px] font-mono mt-0.5">
<span className="text-muted-foreground">{k}: </span>
<span className="text-primary">{v}</span>
</div>
))}
</div>
</motion.div>
)}
</div>
);
}

View File

@@ -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() {
</Card>
</div>
{/* Cluster visualization */}
{/* Cluster Topology — animated interactive visualization */}
<Card className="bg-card border-border/50 overflow-hidden">
<CardHeader className="pb-3">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Network className="w-4 h-4 text-primary" />
Cluster Topology
<span className="ml-auto text-[10px] font-mono text-muted-foreground flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-neon-green pulse-indicator" />
live
{stats && (
<span className="ml-2">&middot; Uptime: <span className="text-primary">{stats.uptime}</span></span>
)}
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="relative h-64">
<img
src={SWARM_IMG}
alt="Swarm Cluster Topology"
className="w-full h-full object-cover opacity-60"
/>
<div className="absolute inset-0 bg-gradient-to-t from-card via-transparent to-card/50" />
<div className="absolute bottom-4 left-6 right-6 flex items-center justify-between">
<div className="font-mono text-[11px] text-muted-foreground">
Overlay Network: <span className="text-primary">goclaw-net</span>
{stats && (
<span className="ml-2 text-muted-foreground">&middot; Uptime: <span className="text-primary">{stats.uptime}</span></span>
)}
</div>
<div className="flex items-center gap-4 font-mono text-[10px]">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-neon-green" /> Manager</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-primary" /> Worker</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-neon-amber" /> Drain</span>
</div>
</div>
</div>
<CardContent className="p-2 pt-0">
<ClusterTopology />
</CardContent>
</Card>
</div>