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

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Expose `/api/mesh/state` as a real cluster snapshot API that aggregates peer-local mesh state and powers Nodes topology from actual `knownPeers` relationships instead of fabricated node-list links.

View File

@@ -234,6 +234,14 @@ Lifecycle contract (`types.ts` `isValidApprovalRequestTransition`):
- Direct `pending -> completed` and all transitions from `denied`/`completed` (except no-op self-transition) are rejected
- Same-state transitions (`from === to`) are treated as valid by the helper even though the intended lifecycle is forward-only
### Mesh state read path for dashboard topology
- `GET /api/mesh/state` in `packages/dashboard/src/routes/register-mesh-routes.ts` is the authoritative dashboard/API read path for topology.
- Default behavior aggregates a deduped cluster snapshot from the local node plus reachable peers (`includeRemote !== false`) while preserving node-local last-known entries when peers are unreachable.
- `includeRemote=false` is the non-recursive local-only path used for peer fan-out, so cross-node aggregation never recursively calls remote aggregated endpoints.
- Route registration reuses the shared `options?.centralCore` instance when available instead of creating per-request `CentralCore` instances, preserving shared mesh state continuity.
- Nodes UI topology (`MeshTopology` via `useMeshState`) now renders peer relationships from `knownPeers` in this snapshot, including remote↔remote links when present.
### Shared mesh-state snapshot helpers
`packages/core/src/shared-mesh-state.ts` defines a common snapshot envelope for non-task mesh state export/apply:

View File

@@ -32,6 +32,8 @@ Core tables:
Per-project task data remains in each repos `.fusion/fusion.db`.
Peer/mesh coordination spans core + engine, with startup ownership in CLI process entrypoints:
- Topology visibility is now cluster-wide from any connected node: dashboard mesh reads aggregate remote local snapshots and dedupe by `nodeId`, with fallback to last-known local mesh state when a peer is temporarily unreachable.
- `NodeDiscovery` and `NodeConnection` in `@fusion/core` handle discovery and remote node connectivity/auth primitives.
- `PeerExchangeService` in `@fusion/engine` coordinates node-to-node sync/exchange workflows.
- `MeshLeaseManager` in `@fusion/engine` is the single authority for stale lease detection and abandoned-work recovery across nodes.

View File

@@ -1349,6 +1349,7 @@ describe("CentralCore", () => {
const state = await central.getMeshState(local!.id);
expect(state.nodeId).toBe(local!.id);
expect(state.nodeType).toBe("local");
expect(state.metrics).toEqual(metrics);
expect(state.knownPeers).toHaveLength(1);
expect(state.knownPeers[0].peerNodeId).toBe("node_mesh_peer");
@@ -1370,10 +1371,27 @@ describe("CentralCore", () => {
expect(metricsSpy).toHaveBeenCalledTimes(1);
expect(state.nodeName).toBe("local");
expect(state.nodeType).toBe("local");
expect(state.metrics).toEqual(metrics);
expect(state.knownPeers).toEqual([]);
});
it("should return local mesh snapshots for all known nodes", async () => {
const remoteNode = await central.registerNode({
name: "Snapshot Remote",
type: "remote",
url: "https://snapshot-remote.example",
});
const snapshots = await central.getLocalMeshSnapshot();
const remote = snapshots.find((entry) => entry.nodeId === remoteNode.id);
const local = snapshots.find((entry) => entry.nodeType === "local");
expect(local).toBeDefined();
expect(remote).toBeDefined();
expect(remote?.nodeType).toBe("remote");
});
describe("peer exchange methods", () => {
it("should register a gossip peer and preserve its nodeId", async () => {
const peerInfo = {

View File

@@ -1361,6 +1361,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
nodeId: node.id,
nodeName: node.name,
nodeUrl: node.url,
nodeType: node.type,
status: node.status,
metrics: node.systemMetrics ?? null,
lastSeen: node.updatedAt,
@@ -1369,6 +1370,36 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
};
}
/**
* Return mesh snapshots for all locally known nodes from the central registry.
* This is a local-only read path and performs no remote fan-out.
*/
async getLocalMeshSnapshot(): Promise<NodeMeshState[]> {
this.ensureInitialized();
const nodes = await this.listNodes();
const snapshots = await Promise.all(
nodes.map(async (node) => {
try {
return await this.getMeshState(node.id);
} catch {
return {
nodeId: node.id,
nodeName: node.name,
nodeUrl: node.url,
nodeType: node.type,
status: node.status,
metrics: node.systemMetrics ?? null,
lastSeen: node.updatedAt,
connectedAt: node.createdAt,
knownPeers: [],
} satisfies NodeMeshState;
}
}),
);
return snapshots;
}
/**
* Collect a fresh local mesh state snapshot.
*/

View File

@@ -454,6 +454,7 @@ export type {
GlobalConcurrencyState,
IsolationMode,
MeshDiscovery,
MeshClusterSnapshot,
MigrationOptions,
NodeConfig,
NodeMeshState,

View File

@@ -2797,6 +2797,8 @@ export interface NodeMeshState {
nodeName: string;
/** Optional base URL (undefined for local nodes). */
nodeUrl: string | undefined;
/** Runtime node type for this snapshot. */
nodeType: NodeConfig["type"];
/** Current node status. */
status: NodeStatus;
/** Latest metrics payload for the node. */
@@ -2809,6 +2811,16 @@ export interface NodeMeshState {
knownPeers: PeerNode[];
}
/** Cluster-wide mesh topology snapshot merged from local and remote mesh reads. */
export interface MeshClusterSnapshot {
/** ISO timestamp when this aggregate snapshot was assembled. */
collectedAt: string;
/** Node ID that assembled and served the snapshot. */
sourceNodeId: string;
/** Deduplicated per-node mesh snapshots keyed by nodeId semantically. */
nodes: NodeMeshState[];
}
/** Lightweight mesh discovery record for propagating peer awareness. */
export interface MeshDiscovery {
/** Node id that generated this discovery payload. */

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 {

View File

@@ -22,6 +22,8 @@ const mockGetLocalPeerInfo = vi.fn();
const mockGetNode = vi.fn();
const mockUpdateNode = vi.fn();
const mockGetLocalNode = vi.fn();
const mockListNodes = vi.fn();
const mockGetLocalMeshSnapshot = vi.fn();
const mockGetSettingsForSync = vi.fn();
const mockApplyRemoteSettings = vi.fn();
const mockReserveDistributedTaskId = vi.fn();
@@ -49,6 +51,8 @@ vi.mock("@fusion/core", async () => {
getNode: mockGetNode,
updateNode: mockUpdateNode,
getLocalNode: mockGetLocalNode,
listNodes: mockListNodes,
getLocalMeshSnapshot: mockGetLocalMeshSnapshot,
getSettingsForSync: mockGetSettingsForSync,
applyRemoteSettings: mockApplyRemoteSettings,
})),
@@ -204,6 +208,30 @@ describe("POST /api/mesh/sync", () => {
createdAt: "2026-04-01T10:00:00.000Z",
updatedAt: "2026-04-01T12:00:00.000Z",
});
mockListNodes.mockResolvedValue([
{
id: "node_local",
name: "local",
type: "local",
status: "online",
maxConcurrent: 4,
createdAt: "2026-04-01T10:00:00.000Z",
updatedAt: "2026-04-01T12:00:00.000Z",
},
]);
mockGetLocalMeshSnapshot.mockResolvedValue([
{
nodeId: "node_local",
nodeName: "local",
nodeUrl: undefined,
nodeType: "local",
status: "online",
metrics: null,
lastSeen: "2026-04-01T12:00:00.000Z",
connectedAt: "2026-04-01T10:00:00.000Z",
knownPeers: [],
},
]);
const store = new MockStore();
app = createServer(store as unknown as TaskStore);
@@ -824,4 +852,108 @@ describe("/api/mesh/tasks/create", () => {
});
expect(response.status).toBe(401);
});
it("returns local-only mesh snapshot when includeRemote=false", async () => {
const response = await request(app, "GET", "/api/mesh/state?includeRemote=false");
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
sourceNodeId: "node_local",
nodes: [
{
nodeId: "node_local",
nodeType: "local",
},
],
});
});
it("reuses provided centralCore instance", async () => {
const sharedCentral = {
isInitialized: vi.fn().mockReturnValue(true),
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getLocalMeshSnapshot: vi.fn().mockResolvedValue([{
nodeId: "node_local",
nodeName: "local",
nodeUrl: undefined,
nodeType: "local",
status: "online",
metrics: null,
lastSeen: "2026-04-01T12:00:00.000Z",
connectedAt: "2026-04-01T10:00:00.000Z",
knownPeers: [],
}]),
getLocalNode: vi.fn().mockResolvedValue({ id: "node_local", type: "local" }),
listNodes: vi.fn().mockResolvedValue([]),
};
const store = new MockStore();
const sharedApp = createServer(store as unknown as TaskStore, { centralCore: sharedCentral as never });
const response = await request(sharedApp, "GET", "/api/mesh/state?includeRemote=false");
expect(response.status).toBe(200);
expect(sharedCentral.close).not.toHaveBeenCalled();
expect(sharedCentral.getLocalMeshSnapshot).toHaveBeenCalledTimes(1);
});
it("merges remote mesh snapshots and deduplicates by nodeId", async () => {
mockListNodes.mockResolvedValue([
{ id: "node_local", name: "local", type: "local", status: "online", maxConcurrent: 4, createdAt: "2026-04-01T10:00:00.000Z", updatedAt: "2026-04-01T12:00:00.000Z" },
{ id: "node_remote_1", name: "Remote 1", type: "remote", url: "https://remote-1.example.com", status: "online", maxConcurrent: 2, createdAt: "2026-04-01T10:00:00.000Z", updatedAt: "2026-04-01T12:00:00.000Z" },
]);
const fetchSpy = vi.spyOn(globalThis, "fetch" as any).mockResolvedValue({
ok: true,
json: async () => ({
sourceNodeId: "node_remote_1",
collectedAt: "2026-04-01T12:00:00.000Z",
nodes: [
{
nodeId: "node_remote_1",
nodeName: "Remote 1",
nodeUrl: "https://remote-1.example.com",
nodeType: "remote",
status: "online",
metrics: null,
lastSeen: "2026-04-01T12:00:00.000Z",
connectedAt: "2026-04-01T10:00:00.000Z",
knownPeers: [],
},
{
nodeId: "node_local",
nodeName: "local",
nodeType: "local",
status: "online",
metrics: null,
lastSeen: "2026-04-01T12:00:00.000Z",
connectedAt: "2026-04-01T10:00:00.000Z",
knownPeers: [],
},
],
}),
} as Response);
const response = await request(app, "GET", "/api/mesh/state");
fetchSpy.mockRestore();
expect(response.status).toBe(200);
expect((response.body as { nodes: Array<{ nodeId: string }> }).nodes.map((node) => node.nodeId).sort()).toEqual([
"node_local",
"node_remote_1",
]);
});
it("keeps local fallback snapshots when remote fetch fails", async () => {
mockListNodes.mockResolvedValue([
{ id: "node_local", name: "local", type: "local", status: "online", maxConcurrent: 4, createdAt: "2026-04-01T10:00:00.000Z", updatedAt: "2026-04-01T12:00:00.000Z" },
{ id: "node_remote_1", name: "Remote 1", type: "remote", url: "https://remote-1.example.com", status: "online", maxConcurrent: 2, createdAt: "2026-04-01T10:00:00.000Z", updatedAt: "2026-04-01T12:00:00.000Z" },
]);
vi.spyOn(globalThis, "fetch" as any).mockRejectedValue(new Error("offline"));
const response = await request(app, "GET", "/api/mesh/state");
vi.restoreAllMocks();
expect(response.status).toBe(200);
expect((response.body as { nodes: Array<{ nodeId: string }> }).nodes.map((node) => node.nodeId)).toContain("node_local");
});
});

View File

@@ -15,7 +15,8 @@ const mockCheckNodeHealth = vi.fn();
const mockUpdateProject = vi.fn();
const mockAssignProjectToNode = vi.fn();
const mockUnassignProjectFromNode = vi.fn();
const mockGetMeshState = vi.fn();
const mockGetLocalMeshSnapshot = vi.fn();
const mockGetLocalNode = vi.fn();
const mockGetNodeVersionInfo = vi.fn();
const mockSyncPlugins = vi.fn();
const mockCheckVersionCompatibility = vi.fn();
@@ -37,7 +38,8 @@ vi.mock("@fusion/core", async () => {
updateProject: mockUpdateProject,
assignProjectToNode: mockAssignProjectToNode,
unassignProjectFromNode: mockUnassignProjectFromNode,
getMeshState: mockGetMeshState,
getLocalMeshSnapshot: mockGetLocalMeshSnapshot,
getLocalNode: mockGetLocalNode,
getNodeVersionInfo: mockGetNodeVersionInfo,
syncPlugins: mockSyncPlugins,
checkVersionCompatibility: mockCheckVersionCompatibility,
@@ -139,16 +141,20 @@ describe("Node routes", () => {
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
mockGetMeshState.mockResolvedValue({
nodeId: "node_local",
nodeName: "local-node",
nodeUrl: undefined,
status: "online",
metrics: null,
lastSeen: "2026-01-01T00:00:00.000Z",
connectedAt: "2026-01-01T00:00:00.000Z",
knownPeers: [],
});
mockGetLocalNode.mockResolvedValue({ id: "node_local", type: "local" });
mockGetLocalMeshSnapshot.mockResolvedValue([
{
nodeId: "node_local",
nodeName: "local-node",
nodeUrl: undefined,
nodeType: "local",
status: "online",
metrics: null,
lastSeen: "2026-01-01T00:00:00.000Z",
connectedAt: "2026-01-01T00:00:00.000Z",
knownPeers: [],
},
]);
mockGetNodeVersionInfo.mockResolvedValue(undefined);
mockSyncPlugins.mockResolvedValue({
localNodeId: "node_local",
@@ -493,42 +499,38 @@ describe("Node routes", () => {
expect(res.status).toBe(404);
});
it("GET /api/mesh/state returns mesh topology state", async () => {
const localMeshState = {
nodeId: "node_local",
nodeName: "local",
nodeUrl: undefined,
status: "online" as const,
metrics: null,
lastSeen: "2026-01-01T00:00:00.000Z",
connectedAt: "2026-01-01T00:00:00.000Z",
knownPeers: [],
};
const remoteMeshState = {
nodeId: "node_remote",
nodeName: "remote",
nodeUrl: "http://remote:3001",
status: "online" as const,
metrics: { cpuUsage: 30, memoryUsed: 2e9, memoryTotal: 8e9, storageUsed: 100e9, storageTotal: 500e9, uptime: 3600000, reportedAt: "2026-01-01T00:00:00.000Z" },
lastSeen: "2026-01-01T00:00:00.000Z",
connectedAt: "2026-01-01T00:00:00.000Z",
knownPeers: [{ id: "peer_1", nodeId: "node_remote", peerNodeId: "node_local", name: "local", url: "http://localhost:3001", status: "online" as const, lastSeen: "2026-01-01T00:00:00.000Z", connectedAt: "2026-01-01T00:00:00.000Z" }],
};
it("GET /api/mesh/state returns mesh topology snapshot", async () => {
mockListNodes.mockResolvedValue([
makeNode({ id: "node_local", name: "local", type: "local" }),
makeNode({ id: "node_remote", name: "remote", type: "remote", url: "http://remote:3001" }),
]);
mockGetMeshState
.mockResolvedValueOnce(localMeshState)
.mockResolvedValueOnce(remoteMeshState);
const remoteMeshState = {
sourceNodeId: "node_remote",
collectedAt: "2026-01-01T00:00:00.000Z",
nodes: [
{
nodeId: "node_remote",
nodeName: "remote",
nodeUrl: "http://remote:3001",
nodeType: "remote" as const,
status: "online" as const,
metrics: { cpuUsage: 30, memoryUsed: 2e9, memoryTotal: 8e9, storageUsed: 100e9, storageTotal: 500e9, uptime: 3600000, reportedAt: "2026-01-01T00:00:00.000Z" },
lastSeen: "2026-01-01T00:00:00.000Z",
connectedAt: "2026-01-01T00:00:00.000Z",
knownPeers: [{ id: "peer_1", nodeId: "node_remote", peerNodeId: "node_local", name: "local", url: "http://localhost:3001", status: "online" as const, lastSeen: "2026-01-01T00:00:00.000Z", connectedAt: "2026-01-01T00:00:00.000Z" }],
},
],
};
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => remoteMeshState }));
const res = await request(app, "GET", "/api/mesh/state");
expect(res.status).toBe(200);
expect((res.body as any[])).toHaveLength(2);
expect((res.body as any[])[0].nodeId).toBe("node_local");
expect((res.body as any[])[1].nodeId).toBe("node_remote");
expect((res.body as { nodes: Array<{ nodeId: string }> }).nodes).toHaveLength(2);
expect((res.body as { nodes: Array<{ nodeId: string }> }).nodes[0].nodeId).toBe("node_local");
expect((res.body as { nodes: Array<{ nodeId: string }> }).nodes[1].nodeId).toBe("node_remote");
});
it("GET /api/nodes/:id/metrics returns systemMetrics from node", async () => {

View File

@@ -13,6 +13,8 @@ const mockRegisterNode = vi.fn();
const mockUpdateNode = vi.fn();
const mockUnregisterNode = vi.fn();
const mockCheckNodeHealth = vi.fn();
const mockGetLocalNode = vi.fn();
const mockGetLocalMeshSnapshot = vi.fn();
const mockIsDiscoveryActive = vi.fn().mockReturnValue(false);
const mockGetDiscoveryConfig = vi.fn().mockReturnValue(null);
const mockChatStoreInit = vi.fn().mockResolvedValue(undefined);
@@ -30,6 +32,8 @@ vi.mock("@fusion/core", () => {
updateNode = mockUpdateNode;
unregisterNode = mockUnregisterNode;
checkNodeHealth = mockCheckNodeHealth;
getLocalNode = mockGetLocalNode;
getLocalMeshSnapshot = mockGetLocalMeshSnapshot;
isDiscoveryActive = mockIsDiscoveryActive;
getDiscoveryConfig = mockGetDiscoveryConfig;
},
@@ -98,6 +102,20 @@ describe("Node routes", () => {
mockUpdateNode.mockResolvedValue(null);
mockUnregisterNode.mockResolvedValue(undefined);
mockCheckNodeHealth.mockResolvedValue({ status: "online" });
mockGetLocalNode.mockResolvedValue(createMockNode({ id: "local", name: "Local", type: "local" }));
mockGetLocalMeshSnapshot.mockResolvedValue([
{
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: [],
},
]);
mockIsDiscoveryActive.mockReturnValue(false);
mockGetDiscoveryConfig.mockReturnValue(null);
@@ -342,60 +360,72 @@ describe("Node routes", () => {
});
describe("GET /api/mesh/state", () => {
it("returns mesh topology state with connections", async () => {
const nodes = [
it("returns mesh topology snapshot payload", async () => {
mockListNodes.mockResolvedValue([
createMockNode({ id: "local", name: "Local", type: "local" }),
createMockNode({ id: "remote-1", name: "Remote 1", type: "remote", url: "http://remote1:3001" }),
createMockNode({ id: "remote-2", name: "Remote 2", type: "remote", url: "http://remote2:3001" }),
];
mockListNodes.mockResolvedValue(nodes);
]);
mockGetLocalMeshSnapshot.mockResolvedValue([
{
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: [],
},
]);
const res = await get(app, "/api/mesh/state");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(3);
// Local node should have connections to both remote nodes
const localNode = res.body.find((n: any) => n.type === "local");
expect(localNode).toBeDefined();
expect(localNode.connections).toHaveLength(2);
expect(localNode.connections.map((c: any) => c.peerId)).toContain("remote-1");
expect(localNode.connections.map((c: any) => c.peerId)).toContain("remote-2");
});
it("returns empty connections when only local node exists", async () => {
const nodes = [createMockNode({ id: "local", name: "Local", type: "local" })];
mockListNodes.mockResolvedValue(nodes);
const res = await get(app, "/api/mesh/state");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].connections).toHaveLength(0);
expect(res.body).toMatchObject({
sourceNodeId: "local",
nodes: [
{
nodeId: "local",
nodeType: "local",
},
],
});
});
it("fetches remote local mesh state and merges it into response", async () => {
const nodes = [
mockListNodes.mockResolvedValue([
createMockNode({ id: "local", name: "Local", type: "local" }),
createMockNode({ id: "remote-1", name: "Remote 1", type: "remote", url: "http://remote1:3001" }),
];
mockListNodes.mockResolvedValue(nodes);
]);
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ([
{
nodeId: "remote-1",
nodeName: "Remote 1",
nodeUrl: "http://remote1:3001",
type: "local",
status: "online",
metrics: { cpuUsage: 50 },
lastSeen: "2026-01-02T00:00:00.000Z",
connectedAt: "2026-01-01T00:00:00.000Z",
knownPeers: [],
},
]),
json: async () => ({
sourceNodeId: "remote-1",
collectedAt: "2026-01-02T00:00:00.000Z",
nodes: [
{
nodeId: "remote-1",
nodeName: "Remote 1",
nodeUrl: "http://remote1:3001",
nodeType: "remote",
status: "online",
metrics: {
cpuUsage: 50,
memoryUsed: 1024,
memoryTotal: 2048,
storageUsed: 8192,
storageTotal: 16384,
uptime: 120,
reportedAt: "2026-01-02T00:00:00.000Z",
},
lastSeen: "2026-01-02T00:00:00.000Z",
connectedAt: "2026-01-01T00:00:00.000Z",
knownPeers: [],
},
],
}),
});
vi.stubGlobal("fetch", fetchMock);
@@ -406,26 +436,28 @@ describe("Node routes", () => {
"http://remote1:3001/api/mesh/state?includeRemote=false",
expect.objectContaining({ method: "GET" }),
);
const remoteState = res.body.find((entry: { nodeId: string }) => entry.nodeId === "remote-1");
const remoteState = (res.body as { nodes: Array<{ nodeId: string; metrics: unknown }> }).nodes.find((entry) => entry.nodeId === "remote-1");
expect(remoteState).toBeDefined();
expect(remoteState.metrics).toEqual({ cpuUsage: 50 });
expect(remoteState?.metrics).toEqual({
cpuUsage: 50,
memoryUsed: 1024,
memoryTotal: 2048,
storageUsed: 8192,
storageTotal: 16384,
uptime: 120,
reportedAt: "2026-01-02T00:00:00.000Z",
});
});
it("returns only local node state when includeRemote is false", async () => {
const nodes = [
createMockNode({ id: "local", name: "Local", type: "local" }),
createMockNode({ id: "remote-1", name: "Remote 1", type: "remote", url: "http://remote1:3001" }),
];
mockListNodes.mockResolvedValue(nodes);
it("returns local snapshot only when includeRemote is false", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const res = await get(app, "/api/mesh/state?includeRemote=false");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].nodeId).toBe("local");
expect((res.body as { nodes: Array<{ nodeId: string }> }).nodes).toHaveLength(1);
expect((res.body as { nodes: Array<{ nodeId: string }> }).nodes[0].nodeId).toBe("local");
expect(fetchMock).not.toHaveBeenCalled();
});
});

View File

@@ -7,9 +7,10 @@ export const registerMeshRoutes: ApiRouteRegistrar = (ctx) => {
const withCentralCore = async <T>(work: (central: import("@fusion/core").CentralCore) => Promise<T>): Promise<T> => {
const { CentralCore } = await import("@fusion/core");
const central = options?.centralCore ?? new CentralCore();
const shouldClose = !options?.centralCore;
if (shouldClose) {
const sharedCentral = options?.centralCore;
const central = sharedCentral ?? new CentralCore();
const shouldClose = !sharedCentral;
if (!sharedCentral || (typeof central.isInitialized === "function" && !central.isInitialized())) {
await central.init();
}
try {
@@ -71,88 +72,101 @@ export const registerMeshRoutes: ApiRouteRegistrar = (ctx) => {
try {
const includeRemote = req.query.includeRemote !== "false";
const meshState = await withCentralCore(async (central) => {
const nodes = await central.listNodes();
const remoteNodes = nodes.filter((n) => n.type === "remote");
const nodeStates = new Map<string, unknown>();
const { z } = await import("zod");
const metricsSchema = z.object({
cpuUsage: z.number(),
memoryUsed: z.number(),
memoryTotal: z.number(),
storageUsed: z.number(),
storageTotal: z.number(),
uptime: z.number(),
reportedAt: z.string(),
});
const nodeMeshStateSchema = z.object({
nodeId: z.string(),
nodeName: z.string(),
nodeUrl: z.string().optional(),
nodeType: z.enum(["local", "remote"]),
status: z.enum(["online", "offline", "connecting", "error"]),
metrics: metricsSchema.nullable(),
lastSeen: z.string(),
connectedAt: z.string(),
knownPeers: z.array(z.object({
id: z.string(),
nodeId: z.string(),
peerNodeId: z.string(),
name: z.string(),
url: z.string(),
status: z.enum(["online", "offline", "connecting", "error"]),
lastSeen: z.string(),
connectedAt: z.string(),
})),
});
const meshArraySchema = z.array(nodeMeshStateSchema);
const fallbackStateForNode = (node: (typeof nodes)[number]) => {
const connections =
node.type === "local"
? remoteNodes.map((peer) => ({
peerId: peer.id,
peerName: peer.name,
peerUrl: peer.url ?? null,
status: peer.status,
}))
: [];
return {
nodeId: node.id,
nodeName: node.name,
nodeUrl: node.url ?? null,
type: node.type,
status: node.status,
metrics: null,
lastSeen: node.updatedAt ?? null,
connectedAt: node.createdAt ?? null,
knownPeers: connections,
connections,
};
};
for (const node of nodes) {
const state = typeof (central as { getMeshState?: (nodeId?: string) => Promise<unknown> }).getMeshState === "function"
? await (central as { getMeshState: (nodeId?: string) => Promise<unknown> }).getMeshState(node.id)
: null;
nodeStates.set(node.id, state ?? fallbackStateForNode(node));
}
const localSnapshots = await central.getLocalMeshSnapshot();
const sourceNodeId = localSnapshots.find((entry) => entry.nodeType === "local")?.nodeId ?? "unknown";
const nodesById = new Map(localSnapshots.map((entry) => [entry.nodeId, entry]));
if (!includeRemote) {
const localNode = nodes.find((node) => node.type === "local");
return localNode ? [nodeStates.get(localNode.id)] : Array.from(nodeStates.values());
return {
collectedAt: new Date().toISOString(),
sourceNodeId,
nodes: Array.from(nodesById.values()),
};
}
await Promise.all(
const registeredNodes = await central.listNodes();
const remoteNodes = registeredNodes.filter((node) => node.type === "remote" && node.url);
const remoteResults = await Promise.allSettled(
remoteNodes.map(async (remoteNode) => {
if (!remoteNode.url) {
return;
}
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (remoteNode.apiKey) {
headers.Authorization = `Bearer ${remoteNode.apiKey}`;
}
try {
const response = await fetch(`${remoteNode.url.replace(/\/$/, "")}/api/mesh/state?includeRemote=false`, {
method: "GET",
headers,
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(`Remote mesh state request failed (${response.status})`);
}
const payload = await response.json();
if (!Array.isArray(payload)) {
return;
}
for (const remoteState of payload) {
if (remoteState && typeof remoteState === "object" && typeof (remoteState as { nodeId?: unknown }).nodeId === "string") {
nodeStates.set((remoteState as { nodeId: string }).nodeId, remoteState);
}
}
} catch (error) {
emitRemoteRouteDiagnostic({
route: "mesh-state",
message: "Failed to fetch remote mesh state",
nodeId: remoteNode.id,
upstreamPath: "/api/mesh/state",
operationStage: "fetch-remote-mesh-state",
level: "warn",
error,
});
const response = await fetch(`${remoteNode.url!.replace(/\/$/, "")}/api/mesh/state?includeRemote=false`, {
method: "GET",
headers,
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(`Remote mesh state request failed (${response.status})`);
}
const payload = await response.json() as unknown;
if (!payload || typeof payload !== "object") {
throw new Error("Remote mesh state payload was not an object");
}
const remoteNodesPayload = meshArraySchema.parse((payload as { nodes?: unknown }).nodes);
return remoteNodesPayload.map((entry) => ({ ...entry, nodeUrl: entry.nodeUrl ?? undefined }));
}),
);
return Array.from(nodeStates.values());
remoteResults.forEach((result, index) => {
const remoteNode = remoteNodes[index];
if (result.status === "fulfilled") {
for (const snapshot of result.value) {
nodesById.set(snapshot.nodeId, snapshot);
}
return;
}
emitRemoteRouteDiagnostic({
route: "mesh-state",
message: "Failed to fetch remote mesh state",
nodeId: remoteNode?.id,
upstreamPath: "/api/mesh/state",
operationStage: "fetch-remote-mesh-state",
level: "warn",
error: result.reason,
});
});
return {
collectedAt: new Date().toISOString(),
sourceNodeId,
nodes: Array.from(nodesById.values()),
};
});
res.json(meshState);