feat(dashboard): restore animated Cluster Topology SVG visualization

- Restored ClusterTopology.tsx from commit 0f38bb5 (was lost)
- Replaces static SWARM_IMG placeholder with live SVG network diagram
- Shows real Swarm nodes (manager/worker), overlay network hub, services, agents
- Animated data-flow particles on edges with glow filters
- Pulse rings on active/running nodes
- Hover tooltips with detailed info (IP, Docker version, CPU, memory)
- Grid background, glow filters, color-coded legend and live stats
- Auto-refreshes every 15-30s
- Live indicator badge in card header
This commit is contained in:
¨NW¨
2026-04-10 07:37:13 +01:00
parent 6d471f8079
commit 42a4f2d01d
2 changed files with 772 additions and 65 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

@@ -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 (
<div className="space-y-6">
@@ -117,7 +223,9 @@ export default function Dashboard() {
GoClaw Swarm Control Center
</h1>
<p className="text-sm text-muted-foreground mt-1 font-mono">
Кластер <span className="text-primary">goclaw-swarm</span> &middot; 4 ноды &middot; 7 агентов &middot; Overlay Network: <span className="text-primary">goclaw-net</span>
Кластер <span className="text-primary">goclaw-swarm</span>{" "}
&middot; 4 ноды &middot; 7 агентов &middot; Overlay Network:{" "}
<span className="text-primary">goclaw-net</span>
</p>
</div>
</div>
@@ -129,7 +237,9 @@ export default function Dashboard() {
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<Card className={`border ${ollamaConnected ? "border-neon-green/30 bg-neon-green/5" : healthQuery.isLoading ? "border-primary/30 bg-primary/5" : "border-neon-red/30 bg-neon-red/5"}`}>
<Card
className={`border ${ollamaConnected ? "border-neon-green/30 bg-neon-green/5" : healthQuery.isLoading ? "border-primary/30 bg-primary/5" : "border-neon-red/30 bg-neon-red/5"}`}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -143,13 +253,25 @@ export default function Dashboard() {
<div>
<div className="text-sm font-semibold text-foreground flex items-center gap-2">
Ollama Cloud API
<Badge variant="outline" className={`text-[9px] font-mono ${ollamaConnected ? "bg-neon-green/15 text-neon-green border-neon-green/30" : "bg-neon-red/15 text-neon-red border-neon-red/30"}`}>
{healthQuery.isLoading ? "CHECKING..." : ollamaConnected ? "CONNECTED" : "OFFLINE"}
<Badge
variant="outline"
className={`text-[9px] font-mono ${ollamaConnected ? "bg-neon-green/15 text-neon-green border-neon-green/30" : "bg-neon-red/15 text-neon-red border-neon-red/30"}`}
>
{healthQuery.isLoading
? "CHECKING..."
: ollamaConnected
? "CONNECTED"
: "OFFLINE"}
</Badge>
</div>
<div className="text-[11px] font-mono text-muted-foreground">
https://ollama.com/v1
{ollamaConnected && <span className="text-neon-green ml-2">&middot; {ollamaLatency}ms latency &middot; {modelCount} models available</span>}
{ollamaConnected && (
<span className="text-neon-green ml-2">
&middot; {ollamaLatency}ms latency &middot; {modelCount}{" "}
models available
</span>
)}
</div>
</div>
</div>
@@ -157,11 +279,15 @@ export default function Dashboard() {
{ollamaConnected && (
<>
<div className="text-center">
<div className="text-lg font-bold text-primary">{modelCount}</div>
<div className="text-lg font-bold text-primary">
{modelCount}
</div>
<div className="text-muted-foreground">Models</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-neon-green">{ollamaLatency}ms</div>
<div className="text-lg font-bold text-neon-green">
{ollamaLatency}ms
</div>
<div className="text-muted-foreground">Latency</div>
</div>
</>
@@ -219,7 +345,7 @@ export default function Dashboard() {
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{NODES.map((node) => (
{NODES.map(node => (
<motion.div
key={node.id}
initial={{ opacity: 0, x: -10 }}
@@ -228,10 +354,17 @@ export default function Dashboard() {
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${node.status === "ready" ? "bg-neon-green pulse-indicator" : node.status === "drain" ? "bg-neon-amber" : "bg-neon-red"}`} />
<span className="font-mono text-xs font-medium text-foreground">{node.name}</span>
<div
className={`w-2 h-2 rounded-full ${node.status === "ready" ? "bg-neon-green pulse-indicator" : node.status === "drain" ? "bg-neon-amber" : "bg-neon-red"}`}
/>
<span className="font-mono text-xs font-medium text-foreground">
{node.name}
</span>
</div>
<Badge variant="outline" className={`text-[10px] font-mono ${getStatusBadge(node.status)}`}>
<Badge
variant="outline"
className={`text-[10px] font-mono ${getStatusBadge(node.status)}`}
>
{node.status.toUpperCase()}
</Badge>
</div>
@@ -239,16 +372,26 @@ export default function Dashboard() {
<div>
<span className="text-muted-foreground">CPU</span>
<Progress value={node.cpu} className="h-1 mt-1" />
<span className={`${node.cpu > 70 ? "text-neon-amber" : "text-neon-green"}`}>{node.cpu}%</span>
<span
className={`${node.cpu > 70 ? "text-neon-amber" : "text-neon-green"}`}
>
{node.cpu}%
</span>
</div>
<div>
<span className="text-muted-foreground">MEM</span>
<Progress value={node.mem} className="h-1 mt-1" />
<span className={`${node.mem > 70 ? "text-neon-amber" : "text-neon-green"}`}>{node.mem}%</span>
<span
className={`${node.mem > 70 ? "text-neon-amber" : "text-neon-green"}`}
>
{node.mem}%
</span>
</div>
<div>
<span className="text-muted-foreground">CONTAINERS</span>
<div className="text-foreground mt-1">{node.containers}</div>
<div className="text-foreground mt-1">
{node.containers}
</div>
</div>
</div>
</motion.div>
@@ -265,7 +408,7 @@ export default function Dashboard() {
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{AGENTS.map((agent) => (
{AGENTS.map(agent => (
<motion.div
key={agent.id}
initial={{ opacity: 0, x: -10 }}
@@ -274,16 +417,28 @@ export default function Dashboard() {
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${getStatusColor(agent.status).replace("text-", "bg-")} ${agent.status === "running" ? "pulse-indicator" : ""}`} />
<span className="text-xs font-medium text-foreground">{agent.name}</span>
<div
className={`w-2 h-2 rounded-full ${getStatusColor(agent.status).replace("text-", "bg-")} ${agent.status === "running" ? "pulse-indicator" : ""}`}
/>
<span className="text-xs font-medium text-foreground">
{agent.name}
</span>
</div>
<Badge variant="outline" className={`text-[10px] font-mono ${getStatusBadge(agent.status)}`}>
<Badge
variant="outline"
className={`text-[10px] font-mono ${getStatusBadge(agent.status)}`}
>
{agent.status.toUpperCase()}
</Badge>
</div>
<div className="flex items-center justify-between text-[10px] font-mono text-muted-foreground">
<span>Model: <span className="text-primary">{agent.model}</span></span>
<span>Tasks: <span className="text-foreground">{agent.tasks}</span></span>
<span>
Model: <span className="text-primary">{agent.model}</span>
</span>
<span>
Tasks:{" "}
<span className="text-foreground">{agent.tasks}</span>
</span>
</div>
</motion.div>
))}
@@ -309,18 +464,29 @@ export default function Dashboard() {
transition={{ delay: i * 0.05 }}
className="flex items-start gap-3 relative pl-5"
>
<div className={`absolute left-0 top-1.5 w-3.5 h-3.5 rounded-full border-2 ${
entry.type === "success" ? "border-neon-green bg-neon-green/20" :
entry.type === "warning" ? "border-neon-amber bg-neon-amber/20" :
entry.type === "error" ? "border-neon-red bg-neon-red/20" :
"border-primary bg-primary/20"
}`} />
<div
className={`absolute left-0 top-1.5 w-3.5 h-3.5 rounded-full border-2 ${
entry.type === "success"
? "border-neon-green bg-neon-green/20"
: entry.type === "warning"
? "border-neon-amber bg-neon-amber/20"
: entry.type === "error"
? "border-neon-red bg-neon-red/20"
: "border-primary bg-primary/20"
}`}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="font-mono text-[10px] text-muted-foreground">{entry.time}</span>
<span className="text-[11px] font-medium text-primary">{entry.agent}</span>
<span className="font-mono text-[10px] text-muted-foreground">
{entry.time}
</span>
<span className="text-[11px] font-medium text-primary">
{entry.agent}
</span>
</div>
<p className="text-xs text-muted-foreground truncate">{entry.action}</p>
<p className="text-xs text-muted-foreground truncate">
{entry.action}
</p>
</div>
</motion.div>
))}
@@ -335,27 +501,17 @@ export default function Dashboard() {
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Network className="w-4 h-4 text-primary" />
Cluster Topology
<Badge
variant="outline"
className="text-[8px] font-mono px-1.5 py-0 border-neon-green/30 text-neon-green ml-2"
>
<span className="w-1.5 h-1.5 rounded-full bg-neon-green inline-block mr-1 animate-pulse" />
live
</Badge>
</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> &middot; Subnet: 10.0.0.0/24
</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>
<ClusterTopology />
</CardContent>
</Card>
</div>