feat(FN-1228): add Node Dashboard UI with mesh topology and routes tests

This commit is contained in:
gsxdsm
2026-04-09 12:34:03 -07:00
parent ced3ad3be6
commit 07e193b0a0
11 changed files with 806 additions and 9 deletions

View File

@@ -0,0 +1,383 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { request, get } from "../test-request.js";
// ── Mock @fusion/core for node routes ─────────────────────────────────
const mockInit = vi.fn().mockResolvedValue(undefined);
const mockClose = vi.fn().mockResolvedValue(undefined);
const mockListNodes = vi.fn();
const mockGetNode = vi.fn();
const mockRegisterNode = vi.fn();
const mockUpdateNode = vi.fn();
const mockUnregisterNode = vi.fn();
const mockCheckNodeHealth = vi.fn();
const mockIsDiscoveryActive = vi.fn().mockReturnValue(false);
const mockGetDiscoveryConfig = vi.fn().mockReturnValue(null);
vi.mock("@fusion/core", () => {
return {
CentralCore: class MockCentralCore {
init = mockInit;
close = mockClose;
listNodes = mockListNodes;
getNode = mockGetNode;
registerNode = mockRegisterNode;
updateNode = mockUpdateNode;
unregisterNode = mockUnregisterNode;
checkNodeHealth = mockCheckNodeHealth;
isDiscoveryActive = mockIsDiscoveryActive;
getDiscoveryConfig = mockGetDiscoveryConfig;
},
};
});
// ── Mock Store ────────────────────────────────────────────────────────
class MockStore extends EventEmitter {
getRootDir(): string {
return "/tmp/fn-1228-test";
}
getFusionDir(): string {
return "/tmp/fn-1228-test/.fusion";
}
getDatabase() {
return {
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({
run: vi.fn().mockReturnValue({ changes: 0 }),
get: vi.fn(),
all: vi.fn().mockReturnValue([]),
}),
};
}
}
// ── Test helpers ──────────────────────────────────────────────────────
function createMockNode(overrides: Record<string, unknown> = {}) {
return {
id: "node-001",
name: "Test Node",
type: "local" as const,
status: "online" as const,
url: null,
maxConcurrent: 2,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
// ── Tests ─────────────────────────────────────────────────────────────
describe("Node routes", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
beforeEach(async () => {
vi.clearAllMocks();
mockInit.mockResolvedValue(undefined);
mockClose.mockResolvedValue(undefined);
mockListNodes.mockResolvedValue([]);
mockGetNode.mockResolvedValue(null);
mockRegisterNode.mockResolvedValue(null);
mockUpdateNode.mockResolvedValue(null);
mockUnregisterNode.mockResolvedValue(undefined);
mockCheckNodeHealth.mockResolvedValue({ status: "online" });
mockIsDiscoveryActive.mockReturnValue(false);
mockGetDiscoveryConfig.mockReturnValue(null);
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("GET /api/nodes", () => {
it("returns empty array when no nodes registered", async () => {
mockListNodes.mockResolvedValue([]);
const res = await get(app, "/api/nodes");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
expect(mockListNodes).toHaveBeenCalled();
});
it("returns list of registered nodes sorted by name", async () => {
const nodes = [
createMockNode({ id: "node-002", name: "Zebra" }),
createMockNode({ id: "node-001", name: "Alpha" }),
];
mockListNodes.mockResolvedValue(nodes);
const res = await get(app, "/api/nodes");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
expect(res.body[0].name).toBe("Alpha");
expect(res.body[1].name).toBe("Zebra");
});
it("includes node metadata in response", async () => {
const node = createMockNode({
id: "node-001",
name: "Test Node",
type: "remote",
status: "online",
url: "http://192.168.1.100:3001",
maxConcurrent: 3,
});
mockListNodes.mockResolvedValue([node]);
const res = await get(app, "/api/nodes");
expect(res.status).toBe(200);
expect(res.body[0]).toMatchObject({
id: "node-001",
name: "Test Node",
type: "remote",
status: "online",
url: "http://192.168.1.100:3001",
maxConcurrent: 3,
});
});
});
describe("POST /api/nodes", () => {
it("registers a new remote node", async () => {
const newNode = createMockNode({
id: "node-003",
name: "Remote Server",
type: "remote",
url: "http://192.168.1.100:3001",
});
mockRegisterNode.mockResolvedValue(newNode);
const res = await request(
app,
"POST",
"/api/nodes",
JSON.stringify({
name: "Remote Server",
type: "remote",
url: "http://192.168.1.100:3001",
maxConcurrent: 2,
}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(201);
expect(res.body).toMatchObject({
id: "node-003",
name: "Remote Server",
type: "remote",
});
expect(mockRegisterNode).toHaveBeenCalledWith(
expect.objectContaining({
name: "Remote Server",
type: "remote",
url: "http://192.168.1.100:3001",
maxConcurrent: 2,
}),
);
});
it("returns 400 when name is missing", async () => {
const res = await request(
app,
"POST",
"/api/nodes",
JSON.stringify({
type: "remote",
url: "http://example.com",
}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("name");
});
it("returns 400 when url is missing for remote node", async () => {
const res = await request(
app,
"POST",
"/api/nodes",
JSON.stringify({
name: "Test Node",
type: "remote",
}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("url");
});
it("registers a local node without url", async () => {
const localNode = createMockNode({
id: "node-local",
name: "Local Node",
type: "local",
url: null,
});
mockRegisterNode.mockResolvedValue(localNode);
const res = await request(
app,
"POST",
"/api/nodes",
JSON.stringify({
name: "Local Node",
type: "local",
}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(201);
expect(mockRegisterNode).toHaveBeenCalledWith(
expect.objectContaining({
name: "Local Node",
type: "local",
}),
);
});
});
describe("GET /api/nodes/:id", () => {
it("returns node detail for existing node", async () => {
const node = createMockNode({ id: "node-001", name: "Test Node" });
mockGetNode.mockResolvedValue(node);
const res = await get(app, "/api/nodes/node-001");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
id: "node-001",
name: "Test Node",
});
});
it("returns 404 for unknown node", async () => {
mockGetNode.mockResolvedValue(null);
const res = await get(app, "/api/nodes/unknown");
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
});
describe("GET /api/nodes/:id/metrics", () => {
it("returns metrics for local node", async () => {
const node = createMockNode({ id: "node-001", type: "local" });
mockGetNode.mockResolvedValue(node);
const res = await get(app, "/api/nodes/node-001/metrics");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
status: "online",
maxConcurrent: 2,
});
});
it("returns 404 for unknown node", async () => {
mockGetNode.mockResolvedValue(null);
const res = await get(app, "/api/nodes/unknown/metrics");
expect(res.status).toBe(404);
});
});
describe("DELETE /api/nodes/:id", () => {
it("removes node and returns 200 with success", async () => {
const node = createMockNode({ id: "node-001" });
mockGetNode.mockResolvedValue(node);
mockUnregisterNode.mockResolvedValue(undefined);
const res = await request(app, "DELETE", "/api/nodes/node-001");
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(mockUnregisterNode).toHaveBeenCalledWith("node-001");
});
it("returns 404 for unknown node", async () => {
mockGetNode.mockResolvedValue(null);
const res = await request(app, "DELETE", "/api/nodes/unknown");
expect(res.status).toBe(404);
});
});
describe("GET /api/mesh/state", () => {
it("returns mesh topology state with connections", async () => {
const nodes = [
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);
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);
});
});
describe("POST /api/nodes/:id/health-check", () => {
it("triggers health check for existing node", async () => {
const node = createMockNode({ id: "node-001" });
mockGetNode.mockResolvedValue(node);
mockCheckNodeHealth.mockResolvedValue({
status: "online",
responseTimeMs: 50,
});
const res = await request(app, "POST", "/api/nodes/node-001/health-check");
expect(res.status).toBe(200);
// Response wraps healthStatus in { status: healthStatus }
expect(res.body.status).toMatchObject({ status: "online" });
expect(mockCheckNodeHealth).toHaveBeenCalledWith("node-001");
});
it("returns 404 for unknown node (checkNodeHealth throws)", async () => {
mockGetNode.mockResolvedValue(null);
mockCheckNodeHealth.mockRejectedValue(new Error("Node not found"));
const res = await request(app, "POST", "/api/nodes/unknown/health-check");
expect(res.status).toBe(404);
});
});
});

View File

@@ -10612,6 +10612,48 @@ Output ONLY the prompt text (no markdown, no explanations).`;
}
});
// ── Mesh Topology Routes ────────────────────────────────────────────────
/**
* GET /api/mesh/state
* Returns the full mesh topology state with peer connections between nodes.
*/
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();
await central.close();
// Return nodes with connection info for topology visualization
const meshState = nodes.map((node) => ({
id: node.id,
name: node.name,
type: node.type,
status: node.status,
url: node.url ?? null,
// For now, we don't have peer-to-peer connection info,
// so all connections are shown as connected to local node
connections: nodes
.filter((n) => n.id !== node.id)
.map((n) => ({
peerId: n.id,
peerName: n.name,
status: n.status,
})),
}));
res.json(meshState);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
// ── Node Discovery Routes (mDNS / DNS-SD) ────────────────────────────────
/**