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:
551
client/src/components/ClusterTopology.tsx
Normal file
551
client/src/components/ClusterTopology.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">· 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">· 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>
|
||||
|
||||
Reference in New Issue
Block a user