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
This commit is contained in:
Fusion
2026-05-10 16:49:08 -07:00
committed by gsxdsm
parent 17ef50f820
commit 32e76c8cc0
19 changed files with 741 additions and 447 deletions

View File

@@ -33,7 +33,7 @@ import type {
ParticipantType,
NodeConfig,
NodeStatus,
NodeMeshState,
MeshClusterSnapshot,
SystemMetrics,
DiscoveryConfig,
MissionEvent,
@@ -5980,8 +5980,8 @@ export async function fetchNodeMetrics(id: string): Promise<SystemMetrics | null
}
/** Fetch full mesh topology state (all nodes with their metrics and known peers) */
export async function fetchMeshState(): Promise<NodeMeshState[]> {
return api<NodeMeshState[]>("/mesh/state");
export async function fetchMeshState(): Promise<MeshClusterSnapshot> {
return api<MeshClusterSnapshot>("/mesh/state");
}
/** Browse directory entries for the directory picker */

View File

@@ -1,15 +1,15 @@
import { memo, useMemo, type ReactElement } from "react";
import type { NodeInfo } from "../api";
import type { NodeMeshState } from "@fusion/core";
export interface MeshTopologyProps {
nodes: NodeInfo[];
nodes: NodeMeshState[];
className?: string;
}
const STATUS_COLORS: Record<NodeInfo["status"], string> = {
const STATUS_COLORS: Record<NodeMeshState["status"], string> = {
online: "var(--success, var(--color-success))",
offline: "var(--text-dim)",
connecting: "var(--triage)",
connecting: "var(--color-warning)",
error: "var(--color-error)",
};
@@ -19,154 +19,86 @@ 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 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;
// 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]);
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 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}
/>
<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} />
))}
{/* 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}
{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"
>
{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 className="mesh-topology__node-type-text" textAnchor="middle" dominantBaseline="middle">
{node.nodeType === "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 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>
{links.length === 0 && <p className="mesh-topology__notice">Peer-to-peer discovery data unavailable.</p>}
</div>
);
}

View File

@@ -4,6 +4,7 @@ import "./NodesView.css";
import { useNodes } from "../hooks/useNodes";
import { useProjects } from "../hooks/useProjects";
import { useNodeSettingsSync, computeSyncState } from "../hooks/useNodeSettingsSync";
import { useMeshState } from "../hooks/useMeshState";
import type { ManagedDockerNodeInfo, NodeInfo, NodeUpdateInput } from "../api";
import { NodeCard } from "./NodeCard";
import { MeshTopology } from "./MeshTopology";
@@ -34,6 +35,7 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
discoverRemoteProjects,
} = useNodes();
const { projects, refresh: refreshProjects } = useProjects();
const { meshState, loading: meshLoading, error: meshError } = useMeshState();
const { syncStatusMap, pushSettings, pullSettings, syncAuth, trackNode, getAuthSyncState, getAuthProviders } = useNodeSettingsSync();
const {
dockerNodes,
@@ -196,14 +198,13 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
</div>
</div>
{error && <div className="nodes-view-error">{error}</div>}
{(error || meshError) && <div className="nodes-view-error">{error ?? meshError}</div>}
{/* Mesh Topology Visualization */}
{!loading && nodes.length > 0 && (
{!meshLoading && meshState && meshState.nodes.length > 0 && (
<section className="nodes-view-topology" aria-label="Mesh Topology">
<h3 className="nodes-view-section-title">Mesh Topology</h3>
<MeshTopology nodes={nodes} />
<MeshTopology nodes={meshState.nodes} />
</section>
)}

View File

@@ -1,161 +1,52 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MeshTopology } from "../MeshTopology";
import type { NodeInfo } from "../../api";
import type { NodeMeshState } from "@fusion/core";
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
function makeNode(overrides: Partial<NodeMeshState> = {}): NodeMeshState {
return {
id: "node_test",
name: "Test Node",
type: "local",
nodeId: "local",
nodeName: "Local",
nodeUrl: undefined,
nodeType: "local",
status: "online",
maxConcurrent: 2,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metrics: null,
lastSeen: "2026-01-01T00:00:00.000Z",
connectedAt: "2026-01-01T00:00:00.000Z",
knownPeers: [],
...overrides,
};
}
describe("MeshTopology", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders empty state when no nodes provided", () => {
it("renders empty state with no nodes", () => {
render(<MeshTopology nodes={[]} />);
expect(screen.getByText("No nodes to display")).toBeInTheDocument();
});
it("renders empty state when empty array provided", () => {
render(<MeshTopology nodes={[]} />);
const svg = document.querySelector(".mesh-topology__svg");
expect(svg).not.toBeInTheDocument();
});
it("renders local node with correct status color", () => {
const nodes = [makeNode({ id: "local", name: "Local", type: "local", status: "online" })];
render(<MeshTopology nodes={nodes} />);
const circles = document.querySelectorAll(".mesh-topology__node-circle");
expect(circles).toHaveLength(1);
expect(circles[0]).toHaveAttribute("fill", expect.stringContaining("var(--success"));
});
it("renders remote nodes in circular arrangement", () => {
const nodes = [
makeNode({ id: "local", name: "Local", type: "local", status: "online" }),
makeNode({ id: "remote1", name: "Remote 1", type: "remote", status: "online" }),
makeNode({ id: "remote2", name: "Remote 2", type: "remote", status: "offline" }),
];
render(<MeshTopology nodes={nodes} />);
const circles = document.querySelectorAll(".mesh-topology__node-circle");
expect(circles).toHaveLength(3); // 1 local + 2 remote
});
it("renders link lines between local and remote nodes", () => {
const nodes = [
makeNode({ id: "local", name: "Local", type: "local" }),
makeNode({ id: "remote", name: "Remote", type: "remote" }),
];
render(<MeshTopology nodes={nodes} />);
const links = document.querySelectorAll(".mesh-topology__link");
expect(links).toHaveLength(1); // One line from local to remote
});
it("does not render fabricated peer links between remote nodes", () => {
const nodes = [
makeNode({ id: "local", name: "Local", type: "local" }),
makeNode({ id: "remote-1", name: "Remote 1", type: "remote" }),
makeNode({ id: "remote-2", name: "Remote 2", type: "remote" }),
makeNode({ id: "remote-3", name: "Remote 3", type: "remote" }),
it("renders peer-derived links including remote-to-remote edges", () => {
const nodes: NodeMeshState[] = [
makeNode({
nodeId: "local",
knownPeers: [{ id: "p1", nodeId: "local", peerNodeId: "remote-1", name: "Remote 1", url: "http://r1", status: "online", lastSeen: "2026-01-01T00:00:00.000Z", connectedAt: "2026-01-01T00:00:00.000Z" }],
}),
makeNode({
nodeId: "remote-1",
nodeName: "Remote 1",
nodeType: "remote",
nodeUrl: "http://r1",
knownPeers: [{ id: "p2", nodeId: "remote-1", peerNodeId: "remote-2", name: "Remote 2", url: "http://r2", status: "online", lastSeen: "2026-01-01T00:00:00.000Z", connectedAt: "2026-01-01T00:00:00.000Z" }],
}),
makeNode({ nodeId: "remote-2", nodeName: "Remote 2", nodeType: "remote", nodeUrl: "http://r2" }),
];
render(<MeshTopology nodes={nodes} />);
expect(document.querySelectorAll(".mesh-topology__peer-line")).toHaveLength(2);
expect(screen.queryByText("Peer-to-peer discovery data unavailable.")).not.toBeInTheDocument();
});
expect(document.querySelectorAll(".mesh-topology__peer-line")).toHaveLength(0);
it("shows fallback notice when peer data is unavailable", () => {
render(<MeshTopology nodes={[makeNode(), makeNode({ nodeId: "remote", nodeType: "remote", nodeName: "Remote" })]} />);
expect(screen.getByText("Peer-to-peer discovery data unavailable.")).toBeInTheDocument();
});
it("renders legend with status colors", () => {
render(<MeshTopology nodes={[makeNode()]} />);
const legend = document.querySelector(".mesh-topology__legend");
expect(legend).toBeInTheDocument();
expect(screen.getByText("Online")).toBeInTheDocument();
expect(screen.getByText("Offline")).toBeInTheDocument();
expect(screen.getByText("Connecting")).toBeInTheDocument();
expect(screen.getByText("Error")).toBeInTheDocument();
});
it("applies custom className", () => {
const { container } = render(<MeshTopology nodes={[]} className="custom-class" />);
expect(container.firstChild).toHaveClass("custom-class");
expect(container.firstChild).toHaveClass("mesh-topology");
});
it("renders node labels truncated at 12 characters", () => {
const nodes = [makeNode({ name: "This is a very long node name that should be truncated" })];
render(<MeshTopology nodes={nodes} />);
// The label should contain truncated text
const label = document.querySelector(".mesh-topology__node-label");
expect(label).toBeInTheDocument();
});
it("renders SVG with correct viewBox", () => {
const nodes = [
makeNode({ id: "local", name: "Local", type: "local" }),
makeNode({ id: "remote1", name: "Remote 1", type: "remote" }),
makeNode({ id: "remote2", name: "Remote 2", type: "remote" }),
makeNode({ id: "remote3", name: "Remote 3", type: "remote" }),
makeNode({ id: "remote4", name: "Remote 4", type: "remote" }),
];
render(<MeshTopology nodes={nodes} />);
const svg = document.querySelector(".mesh-topology__svg");
expect(svg).toBeInTheDocument();
expect(svg).toHaveAttribute("viewBox");
});
it("renders correct status color for offline nodes", () => {
const nodes = [makeNode({ id: "offline", name: "Offline Node", status: "offline" })];
render(<MeshTopology nodes={nodes} />);
const circles = document.querySelectorAll(".mesh-topology__node-circle");
expect(circles).toHaveLength(1);
expect(circles[0]).toHaveAttribute("fill", expect.stringContaining("var(--text-dim"));
});
it("renders correct status color for error nodes", () => {
const nodes = [makeNode({ id: "error", name: "Error Node", status: "error" })];
render(<MeshTopology nodes={nodes} />);
const circles = document.querySelectorAll(".mesh-topology__node-circle");
expect(circles).toHaveLength(1);
expect(circles[0]).toHaveAttribute("fill", expect.stringContaining("var(--color-error"));
});
it("uses consistent node type badges instead of emoji glyphs", () => {
const nodes = [
makeNode({ id: "local", name: "Local", type: "local" }),
makeNode({ id: "remote", name: "Remote", type: "remote" }),
];
render(<MeshTopology nodes={nodes} />);
const typeBadges = document.querySelectorAll(".mesh-topology__node-type-badge");
expect(typeBadges).toHaveLength(2);
expect(screen.queryByText("🏠")).not.toBeInTheDocument();
expect(screen.queryByText("🌐")).not.toBeInTheDocument();
});
it("renders correct status color for connecting nodes", () => {
const nodes = [makeNode({ id: "connecting", name: "Connecting Node", status: "connecting" })];
render(<MeshTopology nodes={nodes} />);
const circles = document.querySelectorAll(".mesh-topology__node-circle");
expect(circles).toHaveLength(1);
expect(circles[0]).toHaveAttribute("fill", expect.stringContaining("var(--triage"));
});
});

View File

@@ -6,6 +6,7 @@ import { useNodes } from "../../hooks/useNodes";
import { useProjects } from "../../hooks/useProjects";
import { useNodeSettingsSync } from "../../hooks/useNodeSettingsSync";
import { useManagedDockerNodes } from "../../hooks/useManagedDockerNodes";
import { useMeshState } from "../../hooks/useMeshState";
import type { NodeSettingsSyncStatus } from "../../api-node";
vi.mock("../../hooks/useNodes", () => ({
@@ -44,10 +45,15 @@ vi.mock("../../hooks/useManagedDockerNodes", () => ({
useManagedDockerNodes: vi.fn(),
}));
vi.mock("../../hooks/useMeshState", () => ({
useMeshState: vi.fn(),
}));
const mockUseNodes = vi.mocked(useNodes);
const mockUseProjects = vi.mocked(useProjects);
const mockUseNodeSettingsSync = vi.mocked(useNodeSettingsSync);
const mockUseManagedDockerNodes = vi.mocked(useManagedDockerNodes);
const mockUseMeshState = vi.mocked(useMeshState);
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
return {
@@ -134,6 +140,13 @@ beforeEach(() => {
getLogs: vi.fn().mockResolvedValue(""),
create: vi.fn().mockResolvedValue(undefined),
});
mockUseMeshState.mockReturnValue({
meshState: { collectedAt: "2026-01-01T00:00:00.000Z", sourceNodeId: "local", nodes: [] },
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
});
});
describe("NodesView", () => {
@@ -188,6 +201,19 @@ describe("NodesView", () => {
makeNode({ id: "node-2", name: "Beta", status: "offline", type: "remote", url: "https://beta.node" }),
],
}));
mockUseMeshState.mockReturnValue({
meshState: {
collectedAt: "2026-01-01T00:00:00.000Z",
sourceNodeId: "node-1",
nodes: [
{ nodeId: "node-1", nodeName: "Alpha", nodeUrl: undefined, nodeType: "local", status: "online", metrics: null, lastSeen: "2026-01-01T00:00:00.000Z", connectedAt: "2026-01-01T00:00:00.000Z", knownPeers: [] },
{ nodeId: "node-2", nodeName: "Beta", nodeUrl: "https://beta.node", nodeType: "remote", status: "offline", metrics: null, lastSeen: "2026-01-01T00:00:00.000Z", connectedAt: "2026-01-01T00:00:00.000Z", knownPeers: [] },
],
},
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
});
render(<NodesView addToast={vi.fn()} onClose={vi.fn()} />);
@@ -334,6 +360,26 @@ describe("NodesView", () => {
];
mockUseNodes.mockReturnValue(makeUseNodesResult({ nodes: sampleNodes }));
mockUseMeshState.mockReturnValue({
meshState: {
collectedAt: "2026-01-01T00:00:00.000Z",
sourceNodeId: "node-local",
nodes: sampleNodes.map((node) => ({
nodeId: node.id,
nodeName: node.name,
nodeUrl: node.url,
nodeType: node.type,
status: node.status,
metrics: null,
lastSeen: node.updatedAt,
connectedAt: node.createdAt,
knownPeers: [],
})),
},
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
});
render(<NodesView addToast={vi.fn()} onClose={vi.fn()} />);

View File

@@ -0,0 +1,73 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useMeshState } from "../useMeshState";
import * as api from "../../api";
vi.mock("../../api", () => ({
fetchMeshState: vi.fn(),
}));
const mockFetchMeshState = vi.mocked(api.fetchMeshState);
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe("useMeshState", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockFetchMeshState.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("loads mesh state on mount", async () => {
mockFetchMeshState.mockResolvedValueOnce({ collectedAt: "2026-01-01T00:00:00.000Z", sourceNodeId: "local", nodes: [] });
const { result } = renderHook(() => useMeshState());
await act(async () => {
await flushPromises();
});
expect(result.current.loading).toBe(false);
expect(result.current.meshState?.sourceNodeId).toBe("local");
});
it("refresh updates state", async () => {
mockFetchMeshState
.mockResolvedValueOnce({ collectedAt: "2026-01-01T00:00:00.000Z", sourceNodeId: "local", nodes: [] })
.mockResolvedValueOnce({ collectedAt: "2026-01-01T00:01:00.000Z", sourceNodeId: "local", nodes: [{ nodeId: "remote", nodeName: "Remote", nodeUrl: "http://remote", nodeType: "remote", status: "online", metrics: null, lastSeen: "2026-01-01T00:01:00.000Z", connectedAt: "2026-01-01T00:00:00.000Z", knownPeers: [] }] });
const { result } = renderHook(() => useMeshState());
await act(async () => {
await flushPromises();
});
await act(async () => {
await result.current.refresh();
});
expect(result.current.meshState?.nodes).toHaveLength(1);
});
it("retains stale mesh state on refresh error", async () => {
mockFetchMeshState
.mockResolvedValueOnce({ collectedAt: "2026-01-01T00:00:00.000Z", sourceNodeId: "local", nodes: [{ nodeId: "local", nodeName: "Local", nodeUrl: undefined, nodeType: "local", status: "online", metrics: null, lastSeen: "2026-01-01T00:00:00.000Z", connectedAt: "2026-01-01T00:00:00.000Z", knownPeers: [] }] })
.mockRejectedValueOnce(new Error("mesh unavailable"));
const { result } = renderHook(() => useMeshState());
await act(async () => {
await flushPromises();
});
await act(async () => {
await result.current.refresh();
});
expect(result.current.error).toBe("mesh unavailable");
expect(result.current.meshState?.nodes).toHaveLength(1);
});
});

View File

@@ -0,0 +1,86 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { MeshClusterSnapshot } from "@fusion/core";
import { fetchMeshState } from "../api";
const POLL_INTERVAL_MS = 10000;
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
export interface UseMeshStateResult {
meshState: MeshClusterSnapshot | null;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
export function useMeshState(): UseMeshStateResult {
const [meshState, setMeshState] = useState<MeshClusterSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const lastVisibilityRefreshRef = useRef<number>(0);
const refresh = useCallback(async () => {
try {
setError(null);
const data = await fetchMeshState();
setMeshState(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch mesh state");
}
}, []);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
try {
const data = await fetchMeshState();
if (!cancelled) {
setMeshState(data);
setError(null);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to fetch mesh state");
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void load();
const handleVisibilityChange = () => {
if (document.visibilityState !== "visible") return;
const now = Date.now();
if (now - lastVisibilityRefreshRef.current < VISIBILITY_REFRESH_DEBOUNCE_MS) return;
lastVisibilityRefreshRef.current = now;
void refresh();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
cancelled = true;
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [refresh]);
useEffect(() => {
if (loading) return;
intervalRef.current = setInterval(() => {
void refresh();
}, POLL_INTERVAL_MS);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [loading, refresh]);
return { meshState, loading, error, refresh };
}

View File

@@ -40,25 +40,33 @@ async function loadRoadmapView(): Promise<{ default: PluginViewComponent }> {
async function loadCliPrintingPressWizardView(): Promise<{ default: PluginViewComponent }> {
const moduleId = "@fusion-plugin-examples/cli-printing-press/dashboard-view";
const exportName = "CliPrintingPressWizardView";
const mod = await import("@fusion-plugin-examples/cli-printing-press/dashboard-view") as unknown as Record<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
const component = mod[exportName];
if (!component) {
console.warn(`[plugin-views] Missing export ${exportName} from ${moduleId}`);
try {
const mod = await import(/* @vite-ignore */ moduleId) as unknown as Record<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
const component = mod[exportName];
if (!component) {
console.warn(`[plugin-views] Missing export ${exportName} from ${moduleId}`);
return { default: createMissingPluginView(moduleId, exportName) };
}
return { default: component as PluginViewComponent };
} catch {
return { default: createMissingPluginView(moduleId, exportName) };
}
return { default: component as PluginViewComponent };
}
async function loadCliPrintingPressManageView(): Promise<{ default: PluginViewComponent }> {
const moduleId = "@fusion-plugin-examples/cli-printing-press/manage-view";
const exportName = "CliPrintingPressManageView";
const mod = await import("@fusion-plugin-examples/cli-printing-press/manage-view") as unknown as Record<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
const component = mod[exportName];
if (!component) {
console.warn(`[plugin-views] Missing export ${exportName} from ${moduleId}`);
try {
const mod = await import(/* @vite-ignore */ moduleId) as unknown as Record<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
const component = mod[exportName];
if (!component) {
console.warn(`[plugin-views] Missing export ${exportName} from ${moduleId}`);
return { default: createMissingPluginView(moduleId, exportName) };
}
return { default: component as PluginViewComponent };
} catch {
return { default: createMissingPluginView(moduleId, exportName) };
}
return { default: component as PluginViewComponent };
}
export function registerBundledPluginViews(): void {