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
|
- 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
|
- 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
|
### Shared mesh-state snapshot helpers
|
||||||
|
|
||||||
`packages/core/src/shared-mesh-state.ts` defines a common snapshot envelope for non-task mesh state export/apply:
|
`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`.
|
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:
|
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.
|
- `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.
|
- `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.
|
- `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);
|
const state = await central.getMeshState(local!.id);
|
||||||
expect(state.nodeId).toBe(local!.id);
|
expect(state.nodeId).toBe(local!.id);
|
||||||
|
expect(state.nodeType).toBe("local");
|
||||||
expect(state.metrics).toEqual(metrics);
|
expect(state.metrics).toEqual(metrics);
|
||||||
expect(state.knownPeers).toHaveLength(1);
|
expect(state.knownPeers).toHaveLength(1);
|
||||||
expect(state.knownPeers[0].peerNodeId).toBe("node_mesh_peer");
|
expect(state.knownPeers[0].peerNodeId).toBe("node_mesh_peer");
|
||||||
@@ -1370,10 +1371,27 @@ describe("CentralCore", () => {
|
|||||||
|
|
||||||
expect(metricsSpy).toHaveBeenCalledTimes(1);
|
expect(metricsSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(state.nodeName).toBe("local");
|
expect(state.nodeName).toBe("local");
|
||||||
|
expect(state.nodeType).toBe("local");
|
||||||
expect(state.metrics).toEqual(metrics);
|
expect(state.metrics).toEqual(metrics);
|
||||||
expect(state.knownPeers).toEqual([]);
|
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", () => {
|
describe("peer exchange methods", () => {
|
||||||
it("should register a gossip peer and preserve its nodeId", async () => {
|
it("should register a gossip peer and preserve its nodeId", async () => {
|
||||||
const peerInfo = {
|
const peerInfo = {
|
||||||
|
|||||||
@@ -1361,6 +1361,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
|
|||||||
nodeId: node.id,
|
nodeId: node.id,
|
||||||
nodeName: node.name,
|
nodeName: node.name,
|
||||||
nodeUrl: node.url,
|
nodeUrl: node.url,
|
||||||
|
nodeType: node.type,
|
||||||
status: node.status,
|
status: node.status,
|
||||||
metrics: node.systemMetrics ?? null,
|
metrics: node.systemMetrics ?? null,
|
||||||
lastSeen: node.updatedAt,
|
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.
|
* Collect a fresh local mesh state snapshot.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -454,6 +454,7 @@ export type {
|
|||||||
GlobalConcurrencyState,
|
GlobalConcurrencyState,
|
||||||
IsolationMode,
|
IsolationMode,
|
||||||
MeshDiscovery,
|
MeshDiscovery,
|
||||||
|
MeshClusterSnapshot,
|
||||||
MigrationOptions,
|
MigrationOptions,
|
||||||
NodeConfig,
|
NodeConfig,
|
||||||
NodeMeshState,
|
NodeMeshState,
|
||||||
|
|||||||
@@ -2797,6 +2797,8 @@ export interface NodeMeshState {
|
|||||||
nodeName: string;
|
nodeName: string;
|
||||||
/** Optional base URL (undefined for local nodes). */
|
/** Optional base URL (undefined for local nodes). */
|
||||||
nodeUrl: string | undefined;
|
nodeUrl: string | undefined;
|
||||||
|
/** Runtime node type for this snapshot. */
|
||||||
|
nodeType: NodeConfig["type"];
|
||||||
/** Current node status. */
|
/** Current node status. */
|
||||||
status: NodeStatus;
|
status: NodeStatus;
|
||||||
/** Latest metrics payload for the node. */
|
/** Latest metrics payload for the node. */
|
||||||
@@ -2809,6 +2811,16 @@ export interface NodeMeshState {
|
|||||||
knownPeers: PeerNode[];
|
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. */
|
/** Lightweight mesh discovery record for propagating peer awareness. */
|
||||||
export interface MeshDiscovery {
|
export interface MeshDiscovery {
|
||||||
/** Node id that generated this discovery payload. */
|
/** Node id that generated this discovery payload. */
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import type {
|
|||||||
ParticipantType,
|
ParticipantType,
|
||||||
NodeConfig,
|
NodeConfig,
|
||||||
NodeStatus,
|
NodeStatus,
|
||||||
NodeMeshState,
|
MeshClusterSnapshot,
|
||||||
SystemMetrics,
|
SystemMetrics,
|
||||||
DiscoveryConfig,
|
DiscoveryConfig,
|
||||||
MissionEvent,
|
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) */
|
/** Fetch full mesh topology state (all nodes with their metrics and known peers) */
|
||||||
export async function fetchMeshState(): Promise<NodeMeshState[]> {
|
export async function fetchMeshState(): Promise<MeshClusterSnapshot> {
|
||||||
return api<NodeMeshState[]>("/mesh/state");
|
return api<MeshClusterSnapshot>("/mesh/state");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Browse directory entries for the directory picker */
|
/** Browse directory entries for the directory picker */
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { memo, useMemo, type ReactElement } from "react";
|
import { memo, useMemo, type ReactElement } from "react";
|
||||||
import type { NodeInfo } from "../api";
|
import type { NodeMeshState } from "@fusion/core";
|
||||||
|
|
||||||
export interface MeshTopologyProps {
|
export interface MeshTopologyProps {
|
||||||
nodes: NodeInfo[];
|
nodes: NodeMeshState[];
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLORS: Record<NodeInfo["status"], string> = {
|
const STATUS_COLORS: Record<NodeMeshState["status"], string> = {
|
||||||
online: "var(--success, var(--color-success))",
|
online: "var(--success, var(--color-success))",
|
||||||
offline: "var(--text-dim)",
|
offline: "var(--text-dim)",
|
||||||
connecting: "var(--triage)",
|
connecting: "var(--color-warning)",
|
||||||
error: "var(--color-error)",
|
error: "var(--color-error)",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -19,154 +19,86 @@ const MIN_VIEWBOX_SIZE = 300;
|
|||||||
const MAX_REMOTE_DISTANCE = 120;
|
const MAX_REMOTE_DISTANCE = 120;
|
||||||
|
|
||||||
function MeshTopologyInner({ nodes, className }: MeshTopologyProps): ReactElement {
|
function MeshTopologyInner({ nodes, className }: MeshTopologyProps): ReactElement {
|
||||||
// Find the local node (center)
|
const localNode = useMemo(() => nodes.find((n) => n.nodeType === "local") ?? nodes[0], [nodes]);
|
||||||
const localNode = useMemo(() => {
|
const remoteNodes = useMemo(() => nodes.filter((n) => n.nodeId !== localNode?.nodeId), [nodes, localNode?.nodeId]);
|
||||||
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 viewBoxSize = useMemo(() => MIN_VIEWBOX_SIZE + (Math.max(0, remoteNodes.length - 4) * 20), [remoteNodes.length]);
|
||||||
const centerX = viewBoxSize / 2;
|
const centerX = viewBoxSize / 2;
|
||||||
const centerY = viewBoxSize / 2;
|
const centerY = viewBoxSize / 2;
|
||||||
|
|
||||||
// Calculate positions for remote nodes in a circle
|
const nodePositions = useMemo(() => {
|
||||||
const remotePositions = useMemo(() => {
|
const positions = new Map<string, { x: number; y: number; node: NodeMeshState }>();
|
||||||
if (remoteNodes.length === 0) return [];
|
if (localNode) {
|
||||||
|
positions.set(localNode.nodeId, { x: centerX, y: centerY, node: localNode });
|
||||||
const distance = Math.min(MAX_REMOTE_DISTANCE, (viewBoxSize / 2) - NODE_RADIUS - 10);
|
}
|
||||||
const angleStep = (2 * Math.PI) / remoteNodes.length;
|
if (remoteNodes.length > 0) {
|
||||||
const startAngle = -Math.PI / 2; // Start from top
|
const distance = Math.min(MAX_REMOTE_DISTANCE, (viewBoxSize / 2) - NODE_RADIUS - 10);
|
||||||
|
const angleStep = (2 * Math.PI) / remoteNodes.length;
|
||||||
return remoteNodes.map((node, index) => {
|
const startAngle = -Math.PI / 2;
|
||||||
const angle = startAngle + index * angleStep;
|
remoteNodes.forEach((node, index) => {
|
||||||
return {
|
const angle = startAngle + index * angleStep;
|
||||||
node,
|
positions.set(node.nodeId, {
|
||||||
x: centerX + distance * Math.cos(angle),
|
node,
|
||||||
y: centerY + distance * Math.sin(angle),
|
x: centerX + distance * Math.cos(angle),
|
||||||
};
|
y: centerY + distance * Math.sin(angle),
|
||||||
});
|
});
|
||||||
}, [remoteNodes, viewBoxSize, centerX, centerY]);
|
});
|
||||||
|
}
|
||||||
|
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) {
|
if (nodes.length === 0) {
|
||||||
return (
|
return <div className={`mesh-topology mesh-topology--empty ${className ?? ""}`}><div className="mesh-topology__empty-state"><p>No nodes to display</p></div></div>;
|
||||||
<div className={`mesh-topology mesh-topology--empty ${className ?? ""}`}>
|
|
||||||
<div className="mesh-topology__empty-state">
|
|
||||||
<p>No nodes to display</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`mesh-topology ${className ?? ""}`}>
|
<div className={`mesh-topology ${className ?? ""}`}>
|
||||||
<svg
|
<svg className="mesh-topology__svg" viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`} preserveAspectRatio="xMidYMid meet" aria-label="Node mesh topology visualization">
|
||||||
className="mesh-topology__svg"
|
{links.map((link) => (
|
||||||
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
|
<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} />
|
||||||
preserveAspectRatio="xMidYMid meet"
|
|
||||||
aria-label="Node mesh topology visualization"
|
|
||||||
>
|
|
||||||
{/* Lines from local node to remote nodes */}
|
|
||||||
{remotePositions.map((rp) => (
|
|
||||||
<line
|
|
||||||
key={`link-${rp.node.id}`}
|
|
||||||
className="mesh-topology__link"
|
|
||||||
x1={centerX}
|
|
||||||
y1={centerY}
|
|
||||||
x2={rp.x}
|
|
||||||
y2={rp.y}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Local node (center) */}
|
{Array.from(nodePositions.values()).map(({ node, x, y }) => (
|
||||||
{localNode && (
|
<g key={node.nodeId} className="mesh-topology__node" transform={`translate(${x}, ${y})`}>
|
||||||
<g className="mesh-topology__node" transform={`translate(${centerX}, ${centerY})`}>
|
<circle className="mesh-topology__node-circle" r={NODE_RADIUS} fill={STATUS_COLORS[node.status]} aria-label={`${node.nodeName} (${node.status})`} />
|
||||||
<circle
|
<text className="mesh-topology__node-label" y={NODE_RADIUS + LABEL_OFFSET} textAnchor="middle">
|
||||||
className="mesh-topology__node-circle"
|
{node.nodeName.length > 12 ? `${node.nodeName.slice(0, 10)}…` : node.nodeName}
|
||||||
r={NODE_RADIUS}
|
|
||||||
fill={STATUS_COLORS[localNode.status]}
|
|
||||||
aria-label={`${localNode.name} (${localNode.status})`}
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
className="mesh-topology__node-label"
|
|
||||||
y={NODE_RADIUS + LABEL_OFFSET}
|
|
||||||
textAnchor="middle"
|
|
||||||
>
|
|
||||||
{localNode.name.length > 12 ? `${localNode.name.slice(0, 10)}…` : localNode.name}
|
|
||||||
</text>
|
</text>
|
||||||
<g className="mesh-topology__node-type" transform={`translate(0 ${-NODE_RADIUS - 10})`}>
|
<g className="mesh-topology__node-type" transform={`translate(0 ${-NODE_RADIUS - 10})`}>
|
||||||
<circle className="mesh-topology__node-type-badge" r="8" />
|
<circle className="mesh-topology__node-type-badge" r="8" />
|
||||||
<text
|
<text className="mesh-topology__node-type-text" textAnchor="middle" dominantBaseline="middle">
|
||||||
className="mesh-topology__node-type-text"
|
{node.nodeType === "local" ? "L" : "R"}
|
||||||
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>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
<div className="mesh-topology__legend">
|
<div className="mesh-topology__legend">
|
||||||
<div className="mesh-topology__legend-item">
|
<div className="mesh-topology__legend-item"><span className="mesh-topology__legend-dot" style={{ background: STATUS_COLORS.online }} /><span>Online</span></div>
|
||||||
<span className="mesh-topology__legend-dot" style={{ background: STATUS_COLORS.online }} />
|
<div className="mesh-topology__legend-item"><span className="mesh-topology__legend-dot" style={{ background: STATUS_COLORS.offline }} /><span>Offline</span></div>
|
||||||
<span>Online</span>
|
<div className="mesh-topology__legend-item"><span className="mesh-topology__legend-dot" style={{ background: STATUS_COLORS.connecting }} /><span>Connecting</span></div>
|
||||||
</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.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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import "./NodesView.css";
|
|||||||
import { useNodes } from "../hooks/useNodes";
|
import { useNodes } from "../hooks/useNodes";
|
||||||
import { useProjects } from "../hooks/useProjects";
|
import { useProjects } from "../hooks/useProjects";
|
||||||
import { useNodeSettingsSync, computeSyncState } from "../hooks/useNodeSettingsSync";
|
import { useNodeSettingsSync, computeSyncState } from "../hooks/useNodeSettingsSync";
|
||||||
|
import { useMeshState } from "../hooks/useMeshState";
|
||||||
import type { ManagedDockerNodeInfo, NodeInfo, NodeUpdateInput } from "../api";
|
import type { ManagedDockerNodeInfo, NodeInfo, NodeUpdateInput } from "../api";
|
||||||
import { NodeCard } from "./NodeCard";
|
import { NodeCard } from "./NodeCard";
|
||||||
import { MeshTopology } from "./MeshTopology";
|
import { MeshTopology } from "./MeshTopology";
|
||||||
@@ -34,6 +35,7 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
|||||||
discoverRemoteProjects,
|
discoverRemoteProjects,
|
||||||
} = useNodes();
|
} = useNodes();
|
||||||
const { projects, refresh: refreshProjects } = useProjects();
|
const { projects, refresh: refreshProjects } = useProjects();
|
||||||
|
const { meshState, loading: meshLoading, error: meshError } = useMeshState();
|
||||||
const { syncStatusMap, pushSettings, pullSettings, syncAuth, trackNode, getAuthSyncState, getAuthProviders } = useNodeSettingsSync();
|
const { syncStatusMap, pushSettings, pullSettings, syncAuth, trackNode, getAuthSyncState, getAuthProviders } = useNodeSettingsSync();
|
||||||
const {
|
const {
|
||||||
dockerNodes,
|
dockerNodes,
|
||||||
@@ -196,14 +198,13 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="nodes-view-error">{error}</div>}
|
{(error || meshError) && <div className="nodes-view-error">{error ?? meshError}</div>}
|
||||||
|
|
||||||
|
|
||||||
{/* Mesh Topology Visualization */}
|
{/* Mesh Topology Visualization */}
|
||||||
{!loading && nodes.length > 0 && (
|
{!meshLoading && meshState && meshState.nodes.length > 0 && (
|
||||||
<section className="nodes-view-topology" aria-label="Mesh Topology">
|
<section className="nodes-view-topology" aria-label="Mesh Topology">
|
||||||
<h3 className="nodes-view-section-title">Mesh Topology</h3>
|
<h3 className="nodes-view-section-title">Mesh Topology</h3>
|
||||||
<MeshTopology nodes={nodes} />
|
<MeshTopology nodes={meshState.nodes} />
|
||||||
</section>
|
</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 { render, screen } from "@testing-library/react";
|
||||||
import { MeshTopology } from "../MeshTopology";
|
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 {
|
return {
|
||||||
id: "node_test",
|
nodeId: "local",
|
||||||
name: "Test Node",
|
nodeName: "Local",
|
||||||
type: "local",
|
nodeUrl: undefined,
|
||||||
|
nodeType: "local",
|
||||||
status: "online",
|
status: "online",
|
||||||
maxConcurrent: 2,
|
metrics: null,
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
lastSeen: "2026-01-01T00:00:00.000Z",
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
connectedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
knownPeers: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("MeshTopology", () => {
|
describe("MeshTopology", () => {
|
||||||
beforeEach(() => {
|
it("renders empty state with no nodes", () => {
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders empty state when no nodes provided", () => {
|
|
||||||
render(<MeshTopology nodes={[]} />);
|
render(<MeshTopology nodes={[]} />);
|
||||||
expect(screen.getByText("No nodes to display")).toBeInTheDocument();
|
expect(screen.getByText("No nodes to display")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders empty state when empty array provided", () => {
|
it("renders peer-derived links including remote-to-remote edges", () => {
|
||||||
render(<MeshTopology nodes={[]} />);
|
const nodes: NodeMeshState[] = [
|
||||||
const svg = document.querySelector(".mesh-topology__svg");
|
makeNode({
|
||||||
expect(svg).not.toBeInTheDocument();
|
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" }],
|
||||||
|
}),
|
||||||
it("renders local node with correct status color", () => {
|
makeNode({
|
||||||
const nodes = [makeNode({ id: "local", name: "Local", type: "local", status: "online" })];
|
nodeId: "remote-1",
|
||||||
render(<MeshTopology nodes={nodes} />);
|
nodeName: "Remote 1",
|
||||||
|
nodeType: "remote",
|
||||||
const circles = document.querySelectorAll(".mesh-topology__node-circle");
|
nodeUrl: "http://r1",
|
||||||
expect(circles).toHaveLength(1);
|
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" }],
|
||||||
expect(circles[0]).toHaveAttribute("fill", expect.stringContaining("var(--success"));
|
}),
|
||||||
});
|
makeNode({ nodeId: "remote-2", nodeName: "Remote 2", nodeType: "remote", nodeUrl: "http://r2" }),
|
||||||
|
|
||||||
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" }),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
render(<MeshTopology nodes={nodes} />);
|
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();
|
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 { useProjects } from "../../hooks/useProjects";
|
||||||
import { useNodeSettingsSync } from "../../hooks/useNodeSettingsSync";
|
import { useNodeSettingsSync } from "../../hooks/useNodeSettingsSync";
|
||||||
import { useManagedDockerNodes } from "../../hooks/useManagedDockerNodes";
|
import { useManagedDockerNodes } from "../../hooks/useManagedDockerNodes";
|
||||||
|
import { useMeshState } from "../../hooks/useMeshState";
|
||||||
import type { NodeSettingsSyncStatus } from "../../api-node";
|
import type { NodeSettingsSyncStatus } from "../../api-node";
|
||||||
|
|
||||||
vi.mock("../../hooks/useNodes", () => ({
|
vi.mock("../../hooks/useNodes", () => ({
|
||||||
@@ -44,10 +45,15 @@ vi.mock("../../hooks/useManagedDockerNodes", () => ({
|
|||||||
useManagedDockerNodes: vi.fn(),
|
useManagedDockerNodes: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useMeshState", () => ({
|
||||||
|
useMeshState: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockUseNodes = vi.mocked(useNodes);
|
const mockUseNodes = vi.mocked(useNodes);
|
||||||
const mockUseProjects = vi.mocked(useProjects);
|
const mockUseProjects = vi.mocked(useProjects);
|
||||||
const mockUseNodeSettingsSync = vi.mocked(useNodeSettingsSync);
|
const mockUseNodeSettingsSync = vi.mocked(useNodeSettingsSync);
|
||||||
const mockUseManagedDockerNodes = vi.mocked(useManagedDockerNodes);
|
const mockUseManagedDockerNodes = vi.mocked(useManagedDockerNodes);
|
||||||
|
const mockUseMeshState = vi.mocked(useMeshState);
|
||||||
|
|
||||||
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
||||||
return {
|
return {
|
||||||
@@ -134,6 +140,13 @@ beforeEach(() => {
|
|||||||
getLogs: vi.fn().mockResolvedValue(""),
|
getLogs: vi.fn().mockResolvedValue(""),
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
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", () => {
|
describe("NodesView", () => {
|
||||||
@@ -188,6 +201,19 @@ describe("NodesView", () => {
|
|||||||
makeNode({ id: "node-2", name: "Beta", status: "offline", type: "remote", url: "https://beta.node" }),
|
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()} />);
|
render(<NodesView addToast={vi.fn()} onClose={vi.fn()} />);
|
||||||
|
|
||||||
@@ -334,6 +360,26 @@ describe("NodesView", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
mockUseNodes.mockReturnValue(makeUseNodesResult({ nodes: sampleNodes }));
|
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()} />);
|
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 }> {
|
async function loadCliPrintingPressWizardView(): Promise<{ default: PluginViewComponent }> {
|
||||||
const moduleId = "@fusion-plugin-examples/cli-printing-press/dashboard-view";
|
const moduleId = "@fusion-plugin-examples/cli-printing-press/dashboard-view";
|
||||||
const exportName = "CliPrintingPressWizardView";
|
const exportName = "CliPrintingPressWizardView";
|
||||||
const mod = await import("@fusion-plugin-examples/cli-printing-press/dashboard-view") as unknown as Record<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
|
try {
|
||||||
const component = mod[exportName];
|
const mod = await import(/* @vite-ignore */ moduleId) as unknown as Record<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
|
||||||
if (!component) {
|
const component = mod[exportName];
|
||||||
console.warn(`[plugin-views] Missing export ${exportName} from ${moduleId}`);
|
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: createMissingPluginView(moduleId, exportName) };
|
||||||
}
|
}
|
||||||
return { default: component as PluginViewComponent };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCliPrintingPressManageView(): Promise<{ default: PluginViewComponent }> {
|
async function loadCliPrintingPressManageView(): Promise<{ default: PluginViewComponent }> {
|
||||||
const moduleId = "@fusion-plugin-examples/cli-printing-press/manage-view";
|
const moduleId = "@fusion-plugin-examples/cli-printing-press/manage-view";
|
||||||
const exportName = "CliPrintingPressManageView";
|
const exportName = "CliPrintingPressManageView";
|
||||||
const mod = await import("@fusion-plugin-examples/cli-printing-press/manage-view") as unknown as Record<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
|
try {
|
||||||
const component = mod[exportName];
|
const mod = await import(/* @vite-ignore */ moduleId) as unknown as Record<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
|
||||||
if (!component) {
|
const component = mod[exportName];
|
||||||
console.warn(`[plugin-views] Missing export ${exportName} from ${moduleId}`);
|
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: createMissingPluginView(moduleId, exportName) };
|
||||||
}
|
}
|
||||||
return { default: component as PluginViewComponent };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerBundledPluginViews(): void {
|
export function registerBundledPluginViews(): void {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const mockGetLocalPeerInfo = vi.fn();
|
|||||||
const mockGetNode = vi.fn();
|
const mockGetNode = vi.fn();
|
||||||
const mockUpdateNode = vi.fn();
|
const mockUpdateNode = vi.fn();
|
||||||
const mockGetLocalNode = vi.fn();
|
const mockGetLocalNode = vi.fn();
|
||||||
|
const mockListNodes = vi.fn();
|
||||||
|
const mockGetLocalMeshSnapshot = vi.fn();
|
||||||
const mockGetSettingsForSync = vi.fn();
|
const mockGetSettingsForSync = vi.fn();
|
||||||
const mockApplyRemoteSettings = vi.fn();
|
const mockApplyRemoteSettings = vi.fn();
|
||||||
const mockReserveDistributedTaskId = vi.fn();
|
const mockReserveDistributedTaskId = vi.fn();
|
||||||
@@ -49,6 +51,8 @@ vi.mock("@fusion/core", async () => {
|
|||||||
getNode: mockGetNode,
|
getNode: mockGetNode,
|
||||||
updateNode: mockUpdateNode,
|
updateNode: mockUpdateNode,
|
||||||
getLocalNode: mockGetLocalNode,
|
getLocalNode: mockGetLocalNode,
|
||||||
|
listNodes: mockListNodes,
|
||||||
|
getLocalMeshSnapshot: mockGetLocalMeshSnapshot,
|
||||||
getSettingsForSync: mockGetSettingsForSync,
|
getSettingsForSync: mockGetSettingsForSync,
|
||||||
applyRemoteSettings: mockApplyRemoteSettings,
|
applyRemoteSettings: mockApplyRemoteSettings,
|
||||||
})),
|
})),
|
||||||
@@ -204,6 +208,30 @@ describe("POST /api/mesh/sync", () => {
|
|||||||
createdAt: "2026-04-01T10:00:00.000Z",
|
createdAt: "2026-04-01T10:00:00.000Z",
|
||||||
updatedAt: "2026-04-01T12: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();
|
const store = new MockStore();
|
||||||
app = createServer(store as unknown as TaskStore);
|
app = createServer(store as unknown as TaskStore);
|
||||||
@@ -824,4 +852,108 @@ describe("/api/mesh/tasks/create", () => {
|
|||||||
});
|
});
|
||||||
expect(response.status).toBe(401);
|
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 mockUpdateProject = vi.fn();
|
||||||
const mockAssignProjectToNode = vi.fn();
|
const mockAssignProjectToNode = vi.fn();
|
||||||
const mockUnassignProjectFromNode = vi.fn();
|
const mockUnassignProjectFromNode = vi.fn();
|
||||||
const mockGetMeshState = vi.fn();
|
const mockGetLocalMeshSnapshot = vi.fn();
|
||||||
|
const mockGetLocalNode = vi.fn();
|
||||||
const mockGetNodeVersionInfo = vi.fn();
|
const mockGetNodeVersionInfo = vi.fn();
|
||||||
const mockSyncPlugins = vi.fn();
|
const mockSyncPlugins = vi.fn();
|
||||||
const mockCheckVersionCompatibility = vi.fn();
|
const mockCheckVersionCompatibility = vi.fn();
|
||||||
@@ -37,7 +38,8 @@ vi.mock("@fusion/core", async () => {
|
|||||||
updateProject: mockUpdateProject,
|
updateProject: mockUpdateProject,
|
||||||
assignProjectToNode: mockAssignProjectToNode,
|
assignProjectToNode: mockAssignProjectToNode,
|
||||||
unassignProjectFromNode: mockUnassignProjectFromNode,
|
unassignProjectFromNode: mockUnassignProjectFromNode,
|
||||||
getMeshState: mockGetMeshState,
|
getLocalMeshSnapshot: mockGetLocalMeshSnapshot,
|
||||||
|
getLocalNode: mockGetLocalNode,
|
||||||
getNodeVersionInfo: mockGetNodeVersionInfo,
|
getNodeVersionInfo: mockGetNodeVersionInfo,
|
||||||
syncPlugins: mockSyncPlugins,
|
syncPlugins: mockSyncPlugins,
|
||||||
checkVersionCompatibility: mockCheckVersionCompatibility,
|
checkVersionCompatibility: mockCheckVersionCompatibility,
|
||||||
@@ -139,16 +141,20 @@ describe("Node routes", () => {
|
|||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
});
|
});
|
||||||
mockGetMeshState.mockResolvedValue({
|
mockGetLocalNode.mockResolvedValue({ id: "node_local", type: "local" });
|
||||||
nodeId: "node_local",
|
mockGetLocalMeshSnapshot.mockResolvedValue([
|
||||||
nodeName: "local-node",
|
{
|
||||||
nodeUrl: undefined,
|
nodeId: "node_local",
|
||||||
status: "online",
|
nodeName: "local-node",
|
||||||
metrics: null,
|
nodeUrl: undefined,
|
||||||
lastSeen: "2026-01-01T00:00:00.000Z",
|
nodeType: "local",
|
||||||
connectedAt: "2026-01-01T00:00:00.000Z",
|
status: "online",
|
||||||
knownPeers: [],
|
metrics: null,
|
||||||
});
|
lastSeen: "2026-01-01T00:00:00.000Z",
|
||||||
|
connectedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
knownPeers: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
mockGetNodeVersionInfo.mockResolvedValue(undefined);
|
mockGetNodeVersionInfo.mockResolvedValue(undefined);
|
||||||
mockSyncPlugins.mockResolvedValue({
|
mockSyncPlugins.mockResolvedValue({
|
||||||
localNodeId: "node_local",
|
localNodeId: "node_local",
|
||||||
@@ -493,42 +499,38 @@ describe("Node routes", () => {
|
|||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("GET /api/mesh/state returns mesh topology state", async () => {
|
it("GET /api/mesh/state returns mesh topology snapshot", 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" }],
|
|
||||||
};
|
|
||||||
|
|
||||||
mockListNodes.mockResolvedValue([
|
mockListNodes.mockResolvedValue([
|
||||||
makeNode({ id: "node_local", name: "local", type: "local" }),
|
makeNode({ id: "node_local", name: "local", type: "local" }),
|
||||||
makeNode({ id: "node_remote", name: "remote", type: "remote", url: "http://remote:3001" }),
|
makeNode({ id: "node_remote", name: "remote", type: "remote", url: "http://remote:3001" }),
|
||||||
]);
|
]);
|
||||||
mockGetMeshState
|
|
||||||
.mockResolvedValueOnce(localMeshState)
|
const remoteMeshState = {
|
||||||
.mockResolvedValueOnce(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");
|
const res = await request(app, "GET", "/api/mesh/state");
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect((res.body as any[])).toHaveLength(2);
|
expect((res.body as { nodes: Array<{ nodeId: string }> }).nodes).toHaveLength(2);
|
||||||
expect((res.body as any[])[0].nodeId).toBe("node_local");
|
expect((res.body as { nodes: Array<{ nodeId: string }> }).nodes[0].nodeId).toBe("node_local");
|
||||||
expect((res.body as any[])[1].nodeId).toBe("node_remote");
|
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 () => {
|
it("GET /api/nodes/:id/metrics returns systemMetrics from node", async () => {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const mockRegisterNode = vi.fn();
|
|||||||
const mockUpdateNode = vi.fn();
|
const mockUpdateNode = vi.fn();
|
||||||
const mockUnregisterNode = vi.fn();
|
const mockUnregisterNode = vi.fn();
|
||||||
const mockCheckNodeHealth = vi.fn();
|
const mockCheckNodeHealth = vi.fn();
|
||||||
|
const mockGetLocalNode = vi.fn();
|
||||||
|
const mockGetLocalMeshSnapshot = vi.fn();
|
||||||
const mockIsDiscoveryActive = vi.fn().mockReturnValue(false);
|
const mockIsDiscoveryActive = vi.fn().mockReturnValue(false);
|
||||||
const mockGetDiscoveryConfig = vi.fn().mockReturnValue(null);
|
const mockGetDiscoveryConfig = vi.fn().mockReturnValue(null);
|
||||||
const mockChatStoreInit = vi.fn().mockResolvedValue(undefined);
|
const mockChatStoreInit = vi.fn().mockResolvedValue(undefined);
|
||||||
@@ -30,6 +32,8 @@ vi.mock("@fusion/core", () => {
|
|||||||
updateNode = mockUpdateNode;
|
updateNode = mockUpdateNode;
|
||||||
unregisterNode = mockUnregisterNode;
|
unregisterNode = mockUnregisterNode;
|
||||||
checkNodeHealth = mockCheckNodeHealth;
|
checkNodeHealth = mockCheckNodeHealth;
|
||||||
|
getLocalNode = mockGetLocalNode;
|
||||||
|
getLocalMeshSnapshot = mockGetLocalMeshSnapshot;
|
||||||
isDiscoveryActive = mockIsDiscoveryActive;
|
isDiscoveryActive = mockIsDiscoveryActive;
|
||||||
getDiscoveryConfig = mockGetDiscoveryConfig;
|
getDiscoveryConfig = mockGetDiscoveryConfig;
|
||||||
},
|
},
|
||||||
@@ -98,6 +102,20 @@ describe("Node routes", () => {
|
|||||||
mockUpdateNode.mockResolvedValue(null);
|
mockUpdateNode.mockResolvedValue(null);
|
||||||
mockUnregisterNode.mockResolvedValue(undefined);
|
mockUnregisterNode.mockResolvedValue(undefined);
|
||||||
mockCheckNodeHealth.mockResolvedValue({ status: "online" });
|
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);
|
mockIsDiscoveryActive.mockReturnValue(false);
|
||||||
mockGetDiscoveryConfig.mockReturnValue(null);
|
mockGetDiscoveryConfig.mockReturnValue(null);
|
||||||
|
|
||||||
@@ -342,60 +360,72 @@ describe("Node routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /api/mesh/state", () => {
|
describe("GET /api/mesh/state", () => {
|
||||||
it("returns mesh topology state with connections", async () => {
|
it("returns mesh topology snapshot payload", async () => {
|
||||||
const nodes = [
|
mockListNodes.mockResolvedValue([
|
||||||
createMockNode({ id: "local", name: "Local", type: "local" }),
|
createMockNode({ id: "local", name: "Local", type: "local" }),
|
||||||
createMockNode({ id: "remote-1", name: "Remote 1", type: "remote", url: "http://remote1:3001" }),
|
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" }),
|
]);
|
||||||
];
|
mockGetLocalMeshSnapshot.mockResolvedValue([
|
||||||
mockListNodes.mockResolvedValue(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: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
const res = await get(app, "/api/mesh/state");
|
const res = await get(app, "/api/mesh/state");
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body).toHaveLength(3);
|
expect(res.body).toMatchObject({
|
||||||
|
sourceNodeId: "local",
|
||||||
// Local node should have connections to both remote nodes
|
nodes: [
|
||||||
const localNode = res.body.find((n: any) => n.type === "local");
|
{
|
||||||
expect(localNode).toBeDefined();
|
nodeId: "local",
|
||||||
expect(localNode.connections).toHaveLength(2);
|
nodeType: "local",
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches remote local mesh state and merges it into response", async () => {
|
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: "local", name: "Local", type: "local" }),
|
||||||
createMockNode({ id: "remote-1", name: "Remote 1", type: "remote", url: "http://remote1:3001" }),
|
createMockNode({ id: "remote-1", name: "Remote 1", type: "remote", url: "http://remote1:3001" }),
|
||||||
];
|
]);
|
||||||
mockListNodes.mockResolvedValue(nodes);
|
|
||||||
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue({
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ([
|
json: async () => ({
|
||||||
{
|
sourceNodeId: "remote-1",
|
||||||
nodeId: "remote-1",
|
collectedAt: "2026-01-02T00:00:00.000Z",
|
||||||
nodeName: "Remote 1",
|
nodes: [
|
||||||
nodeUrl: "http://remote1:3001",
|
{
|
||||||
type: "local",
|
nodeId: "remote-1",
|
||||||
status: "online",
|
nodeName: "Remote 1",
|
||||||
metrics: { cpuUsage: 50 },
|
nodeUrl: "http://remote1:3001",
|
||||||
lastSeen: "2026-01-02T00:00:00.000Z",
|
nodeType: "remote",
|
||||||
connectedAt: "2026-01-01T00:00:00.000Z",
|
status: "online",
|
||||||
knownPeers: [],
|
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);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
@@ -406,26 +436,28 @@ describe("Node routes", () => {
|
|||||||
"http://remote1:3001/api/mesh/state?includeRemote=false",
|
"http://remote1:3001/api/mesh/state?includeRemote=false",
|
||||||
expect.objectContaining({ method: "GET" }),
|
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).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 () => {
|
it("returns local snapshot only 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);
|
|
||||||
|
|
||||||
const fetchMock = vi.fn();
|
const fetchMock = vi.fn();
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
const res = await get(app, "/api/mesh/state?includeRemote=false");
|
const res = await get(app, "/api/mesh/state?includeRemote=false");
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body).toHaveLength(1);
|
expect((res.body as { nodes: Array<{ nodeId: string }> }).nodes).toHaveLength(1);
|
||||||
expect(res.body[0].nodeId).toBe("local");
|
expect((res.body as { nodes: Array<{ nodeId: string }> }).nodes[0].nodeId).toBe("local");
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
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 withCentralCore = async <T>(work: (central: import("@fusion/core").CentralCore) => Promise<T>): Promise<T> => {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const { CentralCore } = await import("@fusion/core");
|
||||||
const central = options?.centralCore ?? new CentralCore();
|
const sharedCentral = options?.centralCore;
|
||||||
const shouldClose = !options?.centralCore;
|
const central = sharedCentral ?? new CentralCore();
|
||||||
if (shouldClose) {
|
const shouldClose = !sharedCentral;
|
||||||
|
if (!sharedCentral || (typeof central.isInitialized === "function" && !central.isInitialized())) {
|
||||||
await central.init();
|
await central.init();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -71,88 +72,101 @@ export const registerMeshRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
try {
|
try {
|
||||||
const includeRemote = req.query.includeRemote !== "false";
|
const includeRemote = req.query.includeRemote !== "false";
|
||||||
const meshState = await withCentralCore(async (central) => {
|
const meshState = await withCentralCore(async (central) => {
|
||||||
const nodes = await central.listNodes();
|
const { z } = await import("zod");
|
||||||
const remoteNodes = nodes.filter((n) => n.type === "remote");
|
const metricsSchema = z.object({
|
||||||
const nodeStates = new Map<string, unknown>();
|
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 localSnapshots = await central.getLocalMeshSnapshot();
|
||||||
const connections =
|
const sourceNodeId = localSnapshots.find((entry) => entry.nodeType === "local")?.nodeId ?? "unknown";
|
||||||
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 nodesById = new Map(localSnapshots.map((entry) => [entry.nodeId, entry]));
|
||||||
if (!includeRemote) {
|
if (!includeRemote) {
|
||||||
const localNode = nodes.find((node) => node.type === "local");
|
return {
|
||||||
return localNode ? [nodeStates.get(localNode.id)] : Array.from(nodeStates.values());
|
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) => {
|
remoteNodes.map(async (remoteNode) => {
|
||||||
if (!remoteNode.url) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
if (remoteNode.apiKey) {
|
if (remoteNode.apiKey) {
|
||||||
headers.Authorization = `Bearer ${remoteNode.apiKey}`;
|
headers.Authorization = `Bearer ${remoteNode.apiKey}`;
|
||||||
}
|
}
|
||||||
try {
|
const response = await fetch(`${remoteNode.url!.replace(/\/$/, "")}/api/mesh/state?includeRemote=false`, {
|
||||||
const response = await fetch(`${remoteNode.url.replace(/\/$/, "")}/api/mesh/state?includeRemote=false`, {
|
method: "GET",
|
||||||
method: "GET",
|
headers,
|
||||||
headers,
|
signal: AbortSignal.timeout(10_000),
|
||||||
signal: AbortSignal.timeout(10_000),
|
});
|
||||||
});
|
if (!response.ok) {
|
||||||
if (!response.ok) {
|
throw new Error(`Remote mesh state request failed (${response.status})`);
|
||||||
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 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);
|
res.json(meshState);
|
||||||
|
|||||||
Reference in New Issue
Block a user