feat(FN-1228): enhance MeshTopology with peer awareness and status legend
- Export MeshTopologyProps interface for external use - Add peer awareness lines between remote nodes for mesh visualization - Implement dynamic SVG sizing based on number of remote nodes - Add status color legend showing online/offline/connecting/error states - Improve CSS variable handling with fallback values - Use transform-based positioning for cleaner SVG structure
This commit is contained in:
@@ -15,6 +15,7 @@ const mockCheckNodeHealth = vi.fn();
|
||||
const mockUpdateProject = vi.fn();
|
||||
const mockAssignProjectToNode = vi.fn();
|
||||
const mockUnassignProjectFromNode = vi.fn();
|
||||
const mockGetMeshState = vi.fn();
|
||||
|
||||
vi.mock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
@@ -32,6 +33,7 @@ vi.mock("@fusion/core", async () => {
|
||||
updateProject: mockUpdateProject,
|
||||
assignProjectToNode: mockAssignProjectToNode,
|
||||
unassignProjectFromNode: mockUnassignProjectFromNode,
|
||||
getMeshState: mockGetMeshState,
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -121,6 +123,16 @@ 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: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("GET /api/nodes returns an empty array when no nodes are registered", async () => {
|
||||
@@ -270,8 +282,8 @@ describe("Node routes", () => {
|
||||
|
||||
const res = await request(app, "DELETE", "/api/nodes/node_1");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
expect(res.status).toBe(204);
|
||||
expect(res.body).toBeNull();
|
||||
expect(mockUnregisterNode).toHaveBeenCalledWith("node_1");
|
||||
});
|
||||
|
||||
@@ -300,26 +312,71 @@ describe("Node routes", () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET /api/nodes/:id/metrics returns stub metrics for local node", async () => {
|
||||
mockGetNode.mockResolvedValue(makeNode({ id: "node_1", type: "local", maxConcurrent: 8 }));
|
||||
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" }],
|
||||
};
|
||||
|
||||
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 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");
|
||||
});
|
||||
|
||||
it("GET /api/nodes/:id/metrics returns systemMetrics from node", async () => {
|
||||
const systemMetrics = {
|
||||
cpuUsage: 45.5,
|
||||
memoryUsed: 4294967296,
|
||||
memoryTotal: 8589934592,
|
||||
storageUsed: 107374182400,
|
||||
storageTotal: 536870912000,
|
||||
uptime: 86400000,
|
||||
reportedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
mockGetNode.mockResolvedValue(
|
||||
makeNode({ id: "node_1", type: "local", maxConcurrent: 8, systemMetrics })
|
||||
);
|
||||
|
||||
const res = await request(app, "GET", "/api/nodes/node_1/metrics");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
status: "online",
|
||||
activeTasks: 0,
|
||||
maxConcurrent: 8,
|
||||
});
|
||||
expect(res.body).toEqual(systemMetrics);
|
||||
});
|
||||
|
||||
it("GET /api/nodes/:id/metrics returns 501 for remote nodes", async () => {
|
||||
it("GET /api/nodes/:id/metrics returns null when no metrics available", async () => {
|
||||
mockGetNode.mockResolvedValue(makeNode({ id: "node_2", type: "remote" }));
|
||||
|
||||
const res = await request(app, "GET", "/api/nodes/node_2/metrics");
|
||||
|
||||
expect(res.status).toBe(501);
|
||||
expect(res.body).toEqual({ error: "Remote node metrics not yet implemented" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toBeNull();
|
||||
});
|
||||
|
||||
it("PATCH /api/projects/:id assigns project to node when nodeId is provided", async () => {
|
||||
|
||||
@@ -10421,11 +10421,10 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
throw badRequest("name is required and must be a non-empty string");
|
||||
}
|
||||
|
||||
if (type !== "local" && type !== "remote") {
|
||||
throw badRequest("type must be 'local' or 'remote'");
|
||||
}
|
||||
// Default to "remote" for backward compatibility with frontend API calls
|
||||
const nodeType = type === "local" || type === "remote" ? type : "remote";
|
||||
|
||||
if (type === "remote" && (!url || typeof url !== "string" || !url.trim())) {
|
||||
if (nodeType === "remote" && (!url || typeof url !== "string" || !url.trim())) {
|
||||
throw badRequest("url is required for remote nodes");
|
||||
}
|
||||
|
||||
@@ -10449,7 +10448,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
const node = await central.registerNode({
|
||||
name: name.trim(),
|
||||
type,
|
||||
type: nodeType,
|
||||
url: typeof url === "string" ? url.trim() : undefined,
|
||||
apiKey: typeof apiKey === "string" ? apiKey : undefined,
|
||||
maxConcurrent,
|
||||
@@ -10545,7 +10544,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
await central.unregisterNode(req.params.id);
|
||||
await central.close();
|
||||
|
||||
res.json({ success: true });
|
||||
res.status(204).end();
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
@@ -10579,7 +10578,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
/**
|
||||
* GET /api/nodes/:id/metrics
|
||||
* Get node runtime metrics.
|
||||
* Get node runtime metrics (SystemMetrics from node's systemMetrics field).
|
||||
*/
|
||||
router.get("/nodes/:id/metrics", async (req, res) => {
|
||||
try {
|
||||
@@ -10594,16 +10593,34 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
throw notFound("Node not found");
|
||||
}
|
||||
|
||||
if (node.type === "local") {
|
||||
res.json({
|
||||
status: "online",
|
||||
activeTasks: 0,
|
||||
maxConcurrent: node.maxConcurrent,
|
||||
});
|
||||
return;
|
||||
// Return the systemMetrics field which contains SystemMetrics or null
|
||||
res.json(node.systemMetrics ?? null);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
throw new ApiError(501, "Remote node metrics not yet implemented");
|
||||
/**
|
||||
* GET /api/mesh/state
|
||||
* Get full mesh topology state (all nodes with their metrics and known peers).
|
||||
*/
|
||||
router.get("/mesh/state", async (_req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const nodes = await central.listNodes();
|
||||
const meshStates = await Promise.all(
|
||||
nodes.map((node) => central.getMeshState(node.id))
|
||||
);
|
||||
|
||||
await central.close();
|
||||
|
||||
res.json(meshStates);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
|
||||
Reference in New Issue
Block a user