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 35dde6d..815daa8 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -24,32 +24,136 @@ 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"; +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"; const NODES = [ - { id: "node-01", name: "goclaw-manager-01", role: "Manager", status: "ready", cpu: 42, mem: 68, containers: 5, ip: "192.168.1.10" }, - { id: "node-02", name: "goclaw-worker-01", role: "Worker", status: "ready", cpu: 28, mem: 45, containers: 3, ip: "192.168.1.11" }, - { id: "node-03", name: "goclaw-worker-02", role: "Worker", status: "ready", cpu: 15, mem: 32, containers: 2, ip: "192.168.1.12" }, - { id: "node-04", name: "goclaw-worker-03", role: "Worker", status: "drain", cpu: 0, mem: 12, containers: 0, ip: "192.168.1.13" }, + { + id: "node-01", + name: "goclaw-manager-01", + role: "Manager", + status: "ready", + cpu: 42, + mem: 68, + containers: 5, + ip: "192.168.1.10", + }, + { + id: "node-02", + name: "goclaw-worker-01", + role: "Worker", + status: "ready", + cpu: 28, + mem: 45, + containers: 3, + ip: "192.168.1.11", + }, + { + id: "node-03", + name: "goclaw-worker-02", + role: "Worker", + status: "ready", + cpu: 15, + mem: 32, + containers: 2, + ip: "192.168.1.12", + }, + { + id: "node-04", + name: "goclaw-worker-03", + role: "Worker", + status: "drain", + cpu: 0, + mem: 12, + containers: 0, + ip: "192.168.1.13", + }, ]; const AGENTS = [ - { id: "agent-coder", name: "Coder Agent", model: "claude-3.5-sonnet", status: "running", tasks: 3, uptime: "2d 14h" }, - { id: "agent-browser", name: "Browser Agent", model: "gpt-4o", status: "running", tasks: 1, uptime: "2d 14h" }, - { id: "agent-mail", name: "Mail Agent", model: "gpt-4o-mini", status: "idle", tasks: 0, uptime: "2d 14h" }, - { id: "agent-monitor", name: "Monitor Agent", model: "llama-3.1-8b", status: "running", tasks: 5, uptime: "2d 14h" }, - { id: "agent-docs", name: "Docs Agent", model: "claude-3-haiku", status: "error", tasks: 0, uptime: "0h 0m" }, + { + id: "agent-coder", + name: "Coder Agent", + model: "claude-3.5-sonnet", + status: "running", + tasks: 3, + uptime: "2d 14h", + }, + { + id: "agent-browser", + name: "Browser Agent", + model: "gpt-4o", + status: "running", + tasks: 1, + uptime: "2d 14h", + }, + { + id: "agent-mail", + name: "Mail Agent", + model: "gpt-4o-mini", + status: "idle", + tasks: 0, + uptime: "2d 14h", + }, + { + id: "agent-monitor", + name: "Monitor Agent", + model: "llama-3.1-8b", + status: "running", + tasks: 5, + uptime: "2d 14h", + }, + { + id: "agent-docs", + name: "Docs Agent", + model: "claude-3-haiku", + status: "error", + tasks: 0, + uptime: "0h 0m", + }, ]; const ACTIVITY_LOG = [ - { time: "19:24:15", agent: "Coder Agent", action: "Завершил рефакторинг модуля memory.go", type: "success" }, - { time: "19:23:48", agent: "Browser Agent", action: "Открыл https://github.com/goclaw/core", type: "info" }, - { time: "19:22:11", agent: "Monitor Agent", action: "CPU на node-02 превысил 80% (пик)", type: "warning" }, - { time: "19:21:30", agent: "Mail Agent", action: "Обработал 12 входящих писем", type: "success" }, - { time: "19:20:05", agent: "Docs Agent", action: "Ошибка подключения к LLM API", type: "error" }, - { time: "19:18:44", agent: "Coder Agent", action: "Создал новый скилл: ssh_executor.go", type: "success" }, + { + time: "19:24:15", + agent: "Coder Agent", + action: "Завершил рефакторинг модуля memory.go", + type: "success", + }, + { + time: "19:23:48", + agent: "Browser Agent", + action: "Открыл https://github.com/goclaw/core", + type: "info", + }, + { + time: "19:22:11", + agent: "Monitor Agent", + action: "CPU на node-02 превысил 80% (пик)", + type: "warning", + }, + { + time: "19:21:30", + agent: "Mail Agent", + action: "Обработал 12 входящих писем", + type: "success", + }, + { + time: "19:20:05", + agent: "Docs Agent", + action: "Ошибка подключения к LLM API", + type: "error", + }, + { + time: "19:18:44", + agent: "Coder Agent", + action: "Создал новый скилл: ssh_executor.go", + type: "success", + }, ]; function getStatusColor(status: string) { @@ -94,7 +198,9 @@ export default function Dashboard() { const ollamaConnected = healthQuery.data?.connected ?? false; const ollamaLatency = healthQuery.data?.latencyMs ?? 0; - const modelCount = modelsQuery.data?.success ? modelsQuery.data.models.length : 0; + const modelCount = modelsQuery.data?.success + ? modelsQuery.data.models.length + : 0; return (
@@ -117,7 +223,9 @@ export default function Dashboard() { GoClaw Swarm Control Center

- Кластер goclaw-swarm · 4 ноды · 7 агентов · Overlay Network: goclaw-net + Кластер goclaw-swarm{" "} + · 4 ноды · 7 агентов · Overlay Network:{" "} + goclaw-net

@@ -129,7 +237,9 @@ export default function Dashboard() { animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} > - +
@@ -143,13 +253,25 @@ export default function Dashboard() {
Ollama Cloud API - - {healthQuery.isLoading ? "CHECKING..." : ollamaConnected ? "CONNECTED" : "OFFLINE"} + + {healthQuery.isLoading + ? "CHECKING..." + : ollamaConnected + ? "CONNECTED" + : "OFFLINE"}
https://ollama.com/v1 - {ollamaConnected && · {ollamaLatency}ms latency · {modelCount} models available} + {ollamaConnected && ( + + · {ollamaLatency}ms latency · {modelCount}{" "} + models available + + )}
@@ -157,11 +279,15 @@ export default function Dashboard() { {ollamaConnected && ( <>
-
{modelCount}
+
+ {modelCount} +
Models
-
{ollamaLatency}ms
+
+ {ollamaLatency}ms +
Latency
@@ -219,7 +345,7 @@ export default function Dashboard() { - {NODES.map((node) => ( + {NODES.map(node => (
-
- {node.name} +
+ + {node.name} +
- + {node.status.toUpperCase()}
@@ -239,16 +372,26 @@ export default function Dashboard() {
CPU - 70 ? "text-neon-amber" : "text-neon-green"}`}>{node.cpu}% + 70 ? "text-neon-amber" : "text-neon-green"}`} + > + {node.cpu}% +
MEM - 70 ? "text-neon-amber" : "text-neon-green"}`}>{node.mem}% + 70 ? "text-neon-amber" : "text-neon-green"}`} + > + {node.mem}% +
CONTAINERS -
{node.containers}
+
+ {node.containers} +
@@ -265,7 +408,7 @@ export default function Dashboard() { - {AGENTS.map((agent) => ( + {AGENTS.map(agent => (
-
- {agent.name} +
+ + {agent.name} +
- + {agent.status.toUpperCase()}
- Model: {agent.model} - Tasks: {agent.tasks} + + Model: {agent.model} + + + Tasks:{" "} + {agent.tasks} +
))} @@ -309,18 +464,29 @@ export default function Dashboard() { transition={{ delay: i * 0.05 }} className="flex items-start gap-3 relative pl-5" > -
+
- {entry.time} - {entry.agent} + + {entry.time} + + + {entry.agent} +
-

{entry.action}

+

+ {entry.action} +

))} @@ -335,27 +501,17 @@ export default function Dashboard() { Cluster Topology + + + live + -
- Swarm Cluster Topology -
-
-
- Overlay Network: goclaw-net · Subnet: 10.0.0.0/24 -
-
- Manager - Worker - Drain -
-
-
+