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:
5
.changeset/fn-3454-mesh-state.md
Normal file
5
.changeset/fn-3454-mesh-state.md
Normal 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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -454,6 +454,7 @@ export type {
|
||||
GlobalConcurrencyState,
|
||||
IsolationMode,
|
||||
MeshDiscovery,
|
||||
MeshClusterSnapshot,
|
||||
MigrationOptions,
|
||||
NodeConfig,
|
||||
NodeMeshState,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()} />);
|
||||
|
||||
|
||||
73
packages/dashboard/app/hooks/__tests__/useMeshState.test.ts
Normal file
73
packages/dashboard/app/hooks/__tests__/useMeshState.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
86
packages/dashboard/app/hooks/useMeshState.ts
Normal file
86
packages/dashboard/app/hooks/useMeshState.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user