Files
fusion/packages/dashboard/app/components/MeshTopology.tsx
Fusion d6c8f1118b fix(FN-2444): polish node dashboard UX and discovery core handling
- Update node management UI components to use shared form/button classes, semantic color tokens, and accessible interactions
- Refine mesh topology rendering and related dashboard tests for improved readability and behavior coverage
- Adjust discovery routes to reuse injected centralCore instances without unnecessary init/close cycles
- Add/extend tests for shared CentralCore discovery flows and model settings scope save behavior
2026-04-24 09:38:16 -07:00

175 lines
5.9 KiB
TypeScript

import { memo, useMemo, type ReactElement } from "react";
import type { NodeInfo } from "../api";
export interface MeshTopologyProps {
nodes: NodeInfo[];
className?: string;
}
const STATUS_COLORS: Record<NodeInfo["status"], string> = {
online: "var(--success, var(--color-success))",
offline: "var(--text-dim)",
connecting: "var(--triage)",
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 {
// Find the local node (center)
const localNode = useMemo(() => {
return nodes.find((n) => n.type === "local") ?? nodes[0];
}, [nodes]);
// Remote nodes arranged in a circle around the local node
const remoteNodes = useMemo(() => {
return nodes.filter((n) => n.type === "remote");
}, [nodes]);
// Calculate SVG dimensions based on number of remote nodes
const viewBoxSize = useMemo(() => {
const baseSize = MIN_VIEWBOX_SIZE;
const extraForRemotes = Math.max(0, remoteNodes.length - 4) * 20;
return baseSize + extraForRemotes;
}, [remoteNodes.length]);
const centerX = viewBoxSize / 2;
const centerY = viewBoxSize / 2;
// Calculate positions for remote nodes in a circle
const remotePositions = useMemo(() => {
if (remoteNodes.length === 0) return [];
const distance = Math.min(MAX_REMOTE_DISTANCE, (viewBoxSize / 2) - NODE_RADIUS - 10);
const angleStep = (2 * Math.PI) / remoteNodes.length;
const startAngle = -Math.PI / 2; // Start from top
return remoteNodes.map((node, index) => {
const angle = startAngle + index * angleStep;
return {
node,
x: centerX + distance * Math.cos(angle),
y: centerY + distance * Math.sin(angle),
};
});
}, [remoteNodes, viewBoxSize, centerX, centerY]);
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"
>
{/* Lines from local node to remote nodes */}
{remotePositions.map((rp) => (
<line
key={`link-${rp.node.id}`}
className="mesh-topology__link"
x1={centerX}
y1={centerY}
x2={rp.x}
y2={rp.y}
/>
))}
{/* Local node (center) */}
{localNode && (
<g className="mesh-topology__node" transform={`translate(${centerX}, ${centerY})`}>
<circle
className="mesh-topology__node-circle"
r={NODE_RADIUS}
fill={STATUS_COLORS[localNode.status]}
aria-label={`${localNode.name} (${localNode.status})`}
/>
<text
className="mesh-topology__node-label"
y={NODE_RADIUS + LABEL_OFFSET}
textAnchor="middle"
>
{localNode.name.length > 12 ? `${localNode.name.slice(0, 10)}` : localNode.name}
</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"
>
{localNode.type === "local" ? "L" : "R"}
</text>
</g>
</g>
)}
{/* Remote nodes */}
{remotePositions.map((rp) => (
<g key={rp.node.id} className="mesh-topology__node" transform={`translate(${rp.x}, ${rp.y})`}>
<circle
className="mesh-topology__node-circle"
r={NODE_RADIUS}
fill={STATUS_COLORS[rp.node.status]}
aria-label={`${rp.node.name} (${rp.node.status})`}
/>
<text
className="mesh-topology__node-label"
y={NODE_RADIUS + LABEL_OFFSET}
textAnchor="middle"
>
{rp.node.name.length > 12 ? `${rp.node.name.slice(0, 10)}` : rp.node.name}
</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"
>
{rp.node.type === "local" ? "L" : "R"}
</text>
</g>
</g>
))}
</svg>
{/* Legend */}
<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>
<p className="mesh-topology__notice">Peer-to-peer discovery data unavailable.</p>
</div>
);
}
export const MeshTopology = memo(MeshTopologyInner);