Files
fusion/packages/dashboard/app/components/MeshTopology.tsx
Fusion d144a081f1 feat(FN-3454): add typed mesh snapshot API consumed by nodes view
Implements a typed mesh snapshot API in `@fusion/core` (`CentralCore.getMeshSnapshot`, `MeshSnapshot` type) and wires it through the dashboard nodes view via a new `useMeshState` hook, substantially simplifying `MeshTopology.tsx` by replacing internal state management with the centralized snapshot.

Fusion-Task-Id: FN-3454
2026-05-10 16:53:36 -07:00

107 lines
4.9 KiB
TypeScript

import { memo, useMemo, type ReactElement } from "react";
import type { NodeMeshState } from "@fusion/core";
export interface MeshTopologyProps {
nodes: NodeMeshState[];
className?: string;
}
const STATUS_COLORS: Record<NodeMeshState["status"], string> = {
online: "var(--success, var(--color-success))",
offline: "var(--text-dim)",
connecting: "var(--color-warning)",
error: "var(--color-error)",
};
const NODE_RADIUS = 28;
const LABEL_OFFSET = 12;
const MIN_VIEWBOX_SIZE = 300;
const MAX_REMOTE_DISTANCE = 120;
function MeshTopologyInner({ nodes, className }: MeshTopologyProps): ReactElement {
const localNode = useMemo(() => nodes.find((n) => n.nodeType === "local") ?? nodes[0], [nodes]);
const remoteNodes = useMemo(() => nodes.filter((n) => n.nodeId !== localNode?.nodeId), [nodes, localNode?.nodeId]);
const viewBoxSize = useMemo(() => MIN_VIEWBOX_SIZE + (Math.max(0, remoteNodes.length - 4) * 20), [remoteNodes.length]);
const centerX = viewBoxSize / 2;
const centerY = viewBoxSize / 2;
const nodePositions = useMemo(() => {
const positions = new Map<string, { x: number; y: number; node: NodeMeshState }>();
if (localNode) {
positions.set(localNode.nodeId, { x: centerX, y: centerY, node: localNode });
}
if (remoteNodes.length > 0) {
const distance = Math.min(MAX_REMOTE_DISTANCE, (viewBoxSize / 2) - NODE_RADIUS - 10);
const angleStep = (2 * Math.PI) / remoteNodes.length;
const startAngle = -Math.PI / 2;
remoteNodes.forEach((node, index) => {
const angle = startAngle + index * angleStep;
positions.set(node.nodeId, {
node,
x: centerX + distance * Math.cos(angle),
y: centerY + distance * Math.sin(angle),
});
});
}
return positions;
}, [centerX, centerY, localNode, remoteNodes, viewBoxSize]);
const links = useMemo(() => {
const seen = new Set<string>();
const result: Array<{ key: string; from: { x: number; y: number }; to: { x: number; y: number } }> = [];
for (const node of nodes) {
const from = nodePositions.get(node.nodeId);
if (!from) continue;
for (const peer of node.knownPeers) {
const to = nodePositions.get(peer.peerNodeId);
if (!to) continue;
const key = [node.nodeId, peer.peerNodeId].sort().join("::");
if (seen.has(key)) continue;
seen.add(key);
result.push({ key, from, to });
}
}
return result;
}, [nodePositions, nodes]);
if (nodes.length === 0) {
return <div className={`mesh-topology mesh-topology--empty ${className ?? ""}`}><div className="mesh-topology__empty-state"><p>No nodes to display</p></div></div>;
}
return (
<div className={`mesh-topology ${className ?? ""}`}>
<svg className="mesh-topology__svg" viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`} preserveAspectRatio="xMidYMid meet" aria-label="Node mesh topology visualization">
{links.map((link) => (
<line key={link.key} className="mesh-topology__link mesh-topology__peer-line" x1={link.from.x} y1={link.from.y} x2={link.to.x} y2={link.to.y} />
))}
{Array.from(nodePositions.values()).map(({ node, x, y }) => (
<g key={node.nodeId} className="mesh-topology__node" transform={`translate(${x}, ${y})`}>
<circle className="mesh-topology__node-circle" r={NODE_RADIUS} fill={STATUS_COLORS[node.status]} aria-label={`${node.nodeName} (${node.status})`} />
<text className="mesh-topology__node-label" y={NODE_RADIUS + LABEL_OFFSET} textAnchor="middle">
{node.nodeName.length > 12 ? `${node.nodeName.slice(0, 10)}` : node.nodeName}
</text>
<g className="mesh-topology__node-type" transform={`translate(0 ${-NODE_RADIUS - 10})`}>
<circle className="mesh-topology__node-type-badge" r="8" />
<text className="mesh-topology__node-type-text" textAnchor="middle" dominantBaseline="middle">
{node.nodeType === "local" ? "L" : "R"}
</text>
</g>
</g>
))}
</svg>
<div className="mesh-topology__legend">
<div className="mesh-topology__legend-item"><span className="mesh-topology__legend-dot" style={{ background: STATUS_COLORS.online }} /><span>Online</span></div>
<div className="mesh-topology__legend-item"><span className="mesh-topology__legend-dot" style={{ background: STATUS_COLORS.offline }} /><span>Offline</span></div>
<div className="mesh-topology__legend-item"><span className="mesh-topology__legend-dot" style={{ background: STATUS_COLORS.connecting }} /><span>Connecting</span></div>
<div className="mesh-topology__legend-item"><span className="mesh-topology__legend-dot" style={{ background: STATUS_COLORS.error }} /><span>Error</span></div>
</div>
{links.length === 0 && <p className="mesh-topology__notice">Peer-to-peer discovery data unavailable.</p>}
</div>
);
}
export const MeshTopology = memo(MeshTopologyInner);