From d144a081f1b4959673f3103180f8c0a1b8a4bba4 Mon Sep 17 00:00:00 2001 From: Fusion Date: Sun, 10 May 2026 16:49:08 -0700 Subject: [PATCH] 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 --- .changeset/fn-3454-mesh-state.md | 5 + docs/architecture.md | 8 + docs/multi-project.md | 2 + .../core/src/__tests__/central-core.test.ts | 18 ++ packages/core/src/central-core.ts | 31 +++ packages/core/src/index.ts | 1 + packages/core/src/types.ts | 12 ++ packages/dashboard/app/api/legacy.ts | 6 +- .../dashboard/app/components/MeshTopology.tsx | 188 ++++++------------ .../dashboard/app/components/NodesView.tsx | 9 +- .../__tests__/MeshTopology.test.tsx | 171 +++------------- .../components/__tests__/NodesView.test.tsx | 46 +++++ .../app/hooks/__tests__/useMeshState.test.ts | 73 +++++++ packages/dashboard/app/hooks/useMeshState.ts | 86 ++++++++ .../app/plugins/registerBundledPluginViews.ts | 28 ++- .../src/__tests__/mesh-routes.test.ts | 132 ++++++++++++ .../src/__tests__/node-routes.test.ts | 82 ++++---- .../src/__tests__/routes-nodes.test.ts | 134 ++++++++----- .../src/routes/register-mesh-routes.ts | 156 ++++++++------- 19 files changed, 741 insertions(+), 447 deletions(-) create mode 100644 .changeset/fn-3454-mesh-state.md create mode 100644 packages/dashboard/app/hooks/__tests__/useMeshState.test.ts create mode 100644 packages/dashboard/app/hooks/useMeshState.ts diff --git a/.changeset/fn-3454-mesh-state.md b/.changeset/fn-3454-mesh-state.md new file mode 100644 index 000000000..4e6a6410f --- /dev/null +++ b/.changeset/fn-3454-mesh-state.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 9f212b9f4..1999ba29f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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: diff --git a/docs/multi-project.md b/docs/multi-project.md index 07f64f4c4..deaaae012 100644 --- a/docs/multi-project.md +++ b/docs/multi-project.md @@ -32,6 +32,8 @@ Core tables: Per-project task data remains in each repo’s `.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. diff --git a/packages/core/src/__tests__/central-core.test.ts b/packages/core/src/__tests__/central-core.test.ts index d1317e996..1b8274280 100644 --- a/packages/core/src/__tests__/central-core.test.ts +++ b/packages/core/src/__tests__/central-core.test.ts @@ -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 = { diff --git a/packages/core/src/central-core.ts b/packages/core/src/central-core.ts index 5c1d61384..851c2af11 100644 --- a/packages/core/src/central-core.ts +++ b/packages/core/src/central-core.ts @@ -1361,6 +1361,7 @@ export class CentralCore extends EventEmitter { 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 { }; } + /** + * 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 { + 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. */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 26ed87bec..89759d7be 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -454,6 +454,7 @@ export type { GlobalConcurrencyState, IsolationMode, MeshDiscovery, + MeshClusterSnapshot, MigrationOptions, NodeConfig, NodeMeshState, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 34bc4cef9..25dec99de 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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. */ diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 73545756e..afb8d725e 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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 { - return api("/mesh/state"); +export async function fetchMeshState(): Promise { + return api("/mesh/state"); } /** Browse directory entries for the directory picker */ diff --git a/packages/dashboard/app/components/MeshTopology.tsx b/packages/dashboard/app/components/MeshTopology.tsx index a64ecef97..307572c8e 100644 --- a/packages/dashboard/app/components/MeshTopology.tsx +++ b/packages/dashboard/app/components/MeshTopology.tsx @@ -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 = { +const STATUS_COLORS: Record = { 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(); + 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(); + 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 ( -
-
-

No nodes to display

-
-
- ); + return

No nodes to display

; } return (
- - {/* Lines from local node to remote nodes */} - {remotePositions.map((rp) => ( - + + {links.map((link) => ( + ))} - {/* Local node (center) */} - {localNode && ( - - - - {localNode.name.length > 12 ? `${localNode.name.slice(0, 10)}…` : localNode.name} + {Array.from(nodePositions.values()).map(({ node, x, y }) => ( + + + + {node.nodeName.length > 12 ? `${node.nodeName.slice(0, 10)}…` : node.nodeName} - - {localNode.type === "local" ? "L" : "R"} - - - - )} - - {/* Remote nodes */} - {remotePositions.map((rp) => ( - - - - {rp.node.name.length > 12 ? `${rp.node.name.slice(0, 10)}…` : rp.node.name} - - - - - {rp.node.type === "local" ? "L" : "R"} + + {node.nodeType === "local" ? "L" : "R"} ))} - {/* Legend */}
-
- - Online -
-
- - Offline -
-
- - Connecting -
-
- - Error -
+
Online
+
Offline
+
Connecting
+
Error
-

Peer-to-peer discovery data unavailable.

+ {links.length === 0 &&

Peer-to-peer discovery data unavailable.

}
); } diff --git a/packages/dashboard/app/components/NodesView.tsx b/packages/dashboard/app/components/NodesView.tsx index 25207ed40..515d154a6 100644 --- a/packages/dashboard/app/components/NodesView.tsx +++ b/packages/dashboard/app/components/NodesView.tsx @@ -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) { - {error &&
{error}
} - + {(error || meshError) &&
{error ?? meshError}
} {/* Mesh Topology Visualization */} - {!loading && nodes.length > 0 && ( + {!meshLoading && meshState && meshState.nodes.length > 0 && (

Mesh Topology

- +
)} diff --git a/packages/dashboard/app/components/__tests__/MeshTopology.test.tsx b/packages/dashboard/app/components/__tests__/MeshTopology.test.tsx index 134c19c08..26c7c8007 100644 --- a/packages/dashboard/app/components/__tests__/MeshTopology.test.tsx +++ b/packages/dashboard/app/components/__tests__/MeshTopology.test.tsx @@ -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 { +function makeNode(overrides: Partial = {}): 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(); expect(screen.getByText("No nodes to display")).toBeInTheDocument(); }); - it("renders empty state when empty array provided", () => { - render(); - 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(); - - 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(); - - 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(); - - 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(); + 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(); expect(screen.getByText("Peer-to-peer discovery data unavailable.")).toBeInTheDocument(); }); - - it("renders legend with status colors", () => { - render(); - - 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(); - 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(); - - // 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - const circles = document.querySelectorAll(".mesh-topology__node-circle"); - expect(circles).toHaveLength(1); - expect(circles[0]).toHaveAttribute("fill", expect.stringContaining("var(--triage")); - }); }); diff --git a/packages/dashboard/app/components/__tests__/NodesView.test.tsx b/packages/dashboard/app/components/__tests__/NodesView.test.tsx index 4e25e194f..42071d2ad 100644 --- a/packages/dashboard/app/components/__tests__/NodesView.test.tsx +++ b/packages/dashboard/app/components/__tests__/NodesView.test.tsx @@ -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 { 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(); @@ -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(); diff --git a/packages/dashboard/app/hooks/__tests__/useMeshState.test.ts b/packages/dashboard/app/hooks/__tests__/useMeshState.test.ts new file mode 100644 index 000000000..8f1566c3a --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useMeshState.test.ts @@ -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 { + 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); + }); +}); diff --git a/packages/dashboard/app/hooks/useMeshState.ts b/packages/dashboard/app/hooks/useMeshState.ts new file mode 100644 index 000000000..dc4011d37 --- /dev/null +++ b/packages/dashboard/app/hooks/useMeshState.ts @@ -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; +} + +export function useMeshState(): UseMeshStateResult { + const [meshState, setMeshState] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const intervalRef = useRef(null); + const lastVisibilityRefreshRef = useRef(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 }; +} diff --git a/packages/dashboard/app/plugins/registerBundledPluginViews.ts b/packages/dashboard/app/plugins/registerBundledPluginViews.ts index 5b39ac744..50ede7b68 100644 --- a/packages/dashboard/app/plugins/registerBundledPluginViews.ts +++ b/packages/dashboard/app/plugins/registerBundledPluginViews.ts @@ -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>; - 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>; + 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>; - 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>; + 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 { diff --git a/packages/dashboard/src/__tests__/mesh-routes.test.ts b/packages/dashboard/src/__tests__/mesh-routes.test.ts index 810fee06a..43a2dd87b 100644 --- a/packages/dashboard/src/__tests__/mesh-routes.test.ts +++ b/packages/dashboard/src/__tests__/mesh-routes.test.ts @@ -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"); + }); }); diff --git a/packages/dashboard/src/__tests__/node-routes.test.ts b/packages/dashboard/src/__tests__/node-routes.test.ts index eafc4bf15..4e924a1ae 100644 --- a/packages/dashboard/src/__tests__/node-routes.test.ts +++ b/packages/dashboard/src/__tests__/node-routes.test.ts @@ -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 () => { diff --git a/packages/dashboard/src/__tests__/routes-nodes.test.ts b/packages/dashboard/src/__tests__/routes-nodes.test.ts index 7bd7a2d42..e1b7102a3 100644 --- a/packages/dashboard/src/__tests__/routes-nodes.test.ts +++ b/packages/dashboard/src/__tests__/routes-nodes.test.ts @@ -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(); }); }); diff --git a/packages/dashboard/src/routes/register-mesh-routes.ts b/packages/dashboard/src/routes/register-mesh-routes.ts index f9f60d018..6533ba545 100644 --- a/packages/dashboard/src/routes/register-mesh-routes.ts +++ b/packages/dashboard/src/routes/register-mesh-routes.ts @@ -7,9 +7,10 @@ export const registerMeshRoutes: ApiRouteRegistrar = (ctx) => { const withCentralCore = async (work: (central: import("@fusion/core").CentralCore) => Promise): Promise => { 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(); + 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 }).getMeshState === "function" - ? await (central as { getMeshState: (nodeId?: string) => Promise }).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 = { "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);