feat(FN-1080): add node management APIs and CLI commands
- Add dashboard /api/nodes CRUD, health-check, and metrics routes backed by CentralCore with request validation - Extend project updates and frontend API types to support node assignment via nodeId - Add dashboard API client helpers for node list/register/update/delete/health/metrics operations - Implement fn node list/add/remove/show/health commands with validation, confirmation, and table/JSON output - Add route and CLI/bin tests plus a minor @gsxdsm/fusion changeset for node management support
This commit is contained in:
@@ -2115,6 +2115,7 @@ export interface ProjectInfo {
|
||||
path: string;
|
||||
status: "active" | "paused" | "errored" | "initializing";
|
||||
isolationMode: "in-process" | "child-process";
|
||||
nodeId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastActivityAt?: string;
|
||||
@@ -2190,6 +2191,28 @@ export interface ProjectCreateInput {
|
||||
isolationMode?: "in-process" | "child-process";
|
||||
}
|
||||
|
||||
/** Node information returned by node endpoints */
|
||||
export interface NodeInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "local" | "remote";
|
||||
url?: string;
|
||||
status: "online" | "offline" | "connecting" | "error";
|
||||
capabilities?: string[];
|
||||
maxConcurrent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Input for creating a new node */
|
||||
export interface NodeCreateInput {
|
||||
name: string;
|
||||
type: "local" | "remote";
|
||||
url?: string;
|
||||
apiKey?: string;
|
||||
maxConcurrent?: number;
|
||||
}
|
||||
|
||||
/** Options for fetching activity feed */
|
||||
export interface FeedOptions {
|
||||
limit?: number;
|
||||
@@ -2253,6 +2276,51 @@ export function fetchProjects(): Promise<ProjectInfo[]> {
|
||||
return api<ProjectInfo[]>("/projects");
|
||||
}
|
||||
|
||||
/** Fetch all registered nodes */
|
||||
export function fetchNodes(): Promise<NodeInfo[]> {
|
||||
return api<NodeInfo[]>("/nodes");
|
||||
}
|
||||
|
||||
/** Register a new node */
|
||||
export function registerNode(input: NodeCreateInput): Promise<NodeInfo> {
|
||||
return api<NodeInfo>("/nodes", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch a single node by ID */
|
||||
export function fetchNode(id: string): Promise<NodeInfo> {
|
||||
return api<NodeInfo>(`/nodes/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
/** Update an existing node */
|
||||
export function updateNode(id: string, updates: Partial<NodeInfo>): Promise<NodeInfo> {
|
||||
return api<NodeInfo>(`/nodes/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
/** Unregister a node */
|
||||
export function unregisterNode(id: string): Promise<void> {
|
||||
return api<void>(`/nodes/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Trigger a node health check */
|
||||
export function checkNodeHealth(id: string): Promise<{ status: string }> {
|
||||
return api<{ status: string }>(`/nodes/${encodeURIComponent(id)}/health-check`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch runtime metrics for a node */
|
||||
export function fetchNodeMetrics(id: string): Promise<Record<string, unknown>> {
|
||||
return api<Record<string, unknown>>(`/nodes/${encodeURIComponent(id)}/metrics`);
|
||||
}
|
||||
|
||||
/** Browse directory entries for the directory picker */
|
||||
export interface BrowseDirectoryResult {
|
||||
currentPath: string;
|
||||
|
||||
344
packages/dashboard/src/__tests__/node-routes.test.ts
Normal file
344
packages/dashboard/src/__tests__/node-routes.test.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { request } from "../test-request.js";
|
||||
import { createServer } from "../server.js";
|
||||
|
||||
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockClose = vi.fn().mockResolvedValue(undefined);
|
||||
const mockListNodes = vi.fn().mockResolvedValue([]);
|
||||
const mockRegisterNode = vi.fn();
|
||||
const mockGetNode = vi.fn();
|
||||
const mockUpdateNode = vi.fn();
|
||||
const mockUnregisterNode = vi.fn().mockResolvedValue(undefined);
|
||||
const mockCheckNodeHealth = vi.fn();
|
||||
const mockUpdateProject = vi.fn();
|
||||
const mockAssignProjectToNode = vi.fn();
|
||||
const mockUnassignProjectFromNode = vi.fn();
|
||||
|
||||
vi.mock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return {
|
||||
...actual,
|
||||
CentralCore: vi.fn().mockImplementation(() => ({
|
||||
init: mockInit,
|
||||
close: mockClose,
|
||||
listNodes: mockListNodes,
|
||||
registerNode: mockRegisterNode,
|
||||
getNode: mockGetNode,
|
||||
updateNode: mockUpdateNode,
|
||||
unregisterNode: mockUnregisterNode,
|
||||
checkNodeHealth: mockCheckNodeHealth,
|
||||
updateProject: mockUpdateProject,
|
||||
assignProjectToNode: mockAssignProjectToNode,
|
||||
unassignProjectFromNode: mockUnassignProjectFromNode,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
getRootDir(): string {
|
||||
return "/tmp/fn-1080";
|
||||
}
|
||||
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
|
||||
};
|
||||
}
|
||||
|
||||
getMissionStore() {
|
||||
return {
|
||||
listMissions: vi.fn().mockResolvedValue([]),
|
||||
createMission: vi.fn(),
|
||||
getMission: vi.fn(),
|
||||
updateMission: vi.fn(),
|
||||
deleteMission: vi.fn(),
|
||||
listTemplates: vi.fn().mockResolvedValue([]),
|
||||
createTemplate: vi.fn(),
|
||||
getTemplate: vi.fn(),
|
||||
updateTemplate: vi.fn(),
|
||||
deleteTemplate: vi.fn(),
|
||||
instantiateMission: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
async listTasks(): Promise<Task[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function makeNode(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: "node_local",
|
||||
name: "local-node",
|
||||
type: "local",
|
||||
status: "online",
|
||||
maxConcurrent: 2,
|
||||
capabilities: ["executor"],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Node routes", () => {
|
||||
const app = createServer(new MockStore() as any);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListNodes.mockResolvedValue([]);
|
||||
mockGetNode.mockResolvedValue(undefined);
|
||||
mockRegisterNode.mockResolvedValue(makeNode());
|
||||
mockUpdateNode.mockResolvedValue(makeNode({ name: "updated-node", maxConcurrent: 4 }));
|
||||
mockCheckNodeHealth.mockResolvedValue("online");
|
||||
mockUpdateProject.mockResolvedValue({
|
||||
id: "proj_123",
|
||||
name: "Project",
|
||||
path: "/tmp/project",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
mockAssignProjectToNode.mockResolvedValue({
|
||||
id: "proj_123",
|
||||
name: "Project",
|
||||
path: "/tmp/project",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
nodeId: "node_local",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
mockUnassignProjectFromNode.mockResolvedValue({
|
||||
id: "proj_123",
|
||||
name: "Project",
|
||||
path: "/tmp/project",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("GET /api/nodes returns an empty array when no nodes are registered", async () => {
|
||||
mockListNodes.mockResolvedValue([]);
|
||||
|
||||
const res = await request(app, "GET", "/api/nodes");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("GET /api/nodes returns node list", async () => {
|
||||
mockListNodes.mockResolvedValue([
|
||||
makeNode({ id: "node_b", name: "z-node" }),
|
||||
makeNode({ id: "node_a", name: "a-node" }),
|
||||
]);
|
||||
|
||||
const res = await request(app, "GET", "/api/nodes");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as any[])).toHaveLength(2);
|
||||
expect((res.body as any[])[0].name).toBe("a-node");
|
||||
expect((res.body as any[])[1].name).toBe("z-node");
|
||||
});
|
||||
|
||||
it("POST /api/nodes registers a local node with minimal input", async () => {
|
||||
mockRegisterNode.mockResolvedValue(makeNode({ id: "node_1", name: "node-one", type: "local" }));
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/nodes",
|
||||
JSON.stringify({ name: "node-one", type: "local" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect((res.body as any).id).toBe("node_1");
|
||||
expect(mockRegisterNode).toHaveBeenCalledWith(expect.objectContaining({ name: "node-one", type: "local" }));
|
||||
});
|
||||
|
||||
it("POST /api/nodes registers a remote node with url", async () => {
|
||||
mockRegisterNode.mockResolvedValue(
|
||||
makeNode({ id: "node_remote", name: "remote-node", type: "remote", url: "https://node.example.com" }),
|
||||
);
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/nodes",
|
||||
JSON.stringify({ name: "remote-node", type: "remote", url: "https://node.example.com" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect((res.body as any).type).toBe("remote");
|
||||
expect(mockRegisterNode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "remote-node", type: "remote", url: "https://node.example.com" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/nodes returns 400 when name is missing", async () => {
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/nodes",
|
||||
JSON.stringify({ type: "local" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /api/nodes returns 400 when remote node is missing url", async () => {
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/nodes",
|
||||
JSON.stringify({ name: "remote-node", type: "remote" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /api/nodes returns 400 for invalid type", async () => {
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/nodes",
|
||||
JSON.stringify({ name: "node", type: "invalid" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("GET /api/nodes/:id returns node by id", async () => {
|
||||
mockGetNode.mockResolvedValue(makeNode({ id: "node_1", name: "node-one" }));
|
||||
|
||||
const res = await request(app, "GET", "/api/nodes/node_1");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as any).id).toBe("node_1");
|
||||
});
|
||||
|
||||
it("GET /api/nodes/:id returns 404 for unknown id", async () => {
|
||||
mockGetNode.mockResolvedValue(undefined);
|
||||
|
||||
const res = await request(app, "GET", "/api/nodes/missing");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("PATCH /api/nodes/:id updates node", async () => {
|
||||
mockUpdateNode.mockResolvedValue(makeNode({ id: "node_1", name: "node-two", maxConcurrent: 6 }));
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"PATCH",
|
||||
"/api/nodes/node_1",
|
||||
JSON.stringify({ name: "node-two", maxConcurrent: 6 }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as any).name).toBe("node-two");
|
||||
expect((res.body as any).maxConcurrent).toBe(6);
|
||||
});
|
||||
|
||||
it("PATCH /api/nodes/:id returns 404 for unknown id", async () => {
|
||||
mockUpdateNode.mockRejectedValue(new Error("Node not found: missing"));
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"PATCH",
|
||||
"/api/nodes/missing",
|
||||
JSON.stringify({ name: "new-name" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("DELETE /api/nodes/:id unregisters node", async () => {
|
||||
mockGetNode.mockResolvedValue(makeNode({ id: "node_1" }));
|
||||
|
||||
const res = await request(app, "DELETE", "/api/nodes/node_1");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
expect(mockUnregisterNode).toHaveBeenCalledWith("node_1");
|
||||
});
|
||||
|
||||
it("DELETE /api/nodes/:id returns 404 for unknown id", async () => {
|
||||
mockGetNode.mockResolvedValue(undefined);
|
||||
|
||||
const res = await request(app, "DELETE", "/api/nodes/missing");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("POST /api/nodes/:id/health-check returns health status", async () => {
|
||||
mockCheckNodeHealth.mockResolvedValue("online");
|
||||
|
||||
const res = await request(app, "POST", "/api/nodes/node_1/health-check");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ status: "online" });
|
||||
});
|
||||
|
||||
it("POST /api/nodes/:id/health-check returns 404 for unknown id", async () => {
|
||||
mockCheckNodeHealth.mockRejectedValue(new Error("Node not found: missing"));
|
||||
|
||||
const res = await request(app, "POST", "/api/nodes/missing/health-check");
|
||||
|
||||
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 }));
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
it("PATCH /api/projects/:id assigns project to node when nodeId is provided", async () => {
|
||||
const res = await request(
|
||||
app,
|
||||
"PATCH",
|
||||
"/api/projects/proj_123",
|
||||
JSON.stringify({ nodeId: "node_local" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockUpdateProject).toHaveBeenCalledWith("proj_123", {});
|
||||
expect(mockAssignProjectToNode).toHaveBeenCalledWith("proj_123", "node_local");
|
||||
expect((res.body as any).nodeId).toBe("node_local");
|
||||
});
|
||||
|
||||
it("PATCH /api/projects/:id unassigns project from node when nodeId is null", async () => {
|
||||
const res = await request(
|
||||
app,
|
||||
"PATCH",
|
||||
"/api/projects/proj_123",
|
||||
JSON.stringify({ nodeId: null }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockUnassignProjectFromNode).toHaveBeenCalledWith("proj_123");
|
||||
expect(res.body).not.toHaveProperty("nodeId");
|
||||
});
|
||||
});
|
||||
@@ -7880,7 +7880,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
*/
|
||||
router.patch("/projects/:id", async (req, res) => {
|
||||
try {
|
||||
const { name, status, isolationMode } = req.body;
|
||||
const { name, status, isolationMode, nodeId } = req.body;
|
||||
|
||||
const updates: Partial<import("@fusion/core").RegisteredProject> = {};
|
||||
if (name !== undefined) updates.name = name;
|
||||
@@ -7892,16 +7892,31 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
await central.init();
|
||||
|
||||
const project = await central.updateProject(req.params.id, updates);
|
||||
await central.close();
|
||||
|
||||
if (!project) {
|
||||
await central.close();
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
let resultProject = project;
|
||||
if (nodeId !== undefined) {
|
||||
if (nodeId === null) {
|
||||
resultProject = await central.unassignProjectFromNode(req.params.id);
|
||||
} else if (typeof nodeId === "string" && nodeId.trim()) {
|
||||
resultProject = await central.assignProjectToNode(req.params.id, nodeId.trim());
|
||||
} else {
|
||||
await central.close();
|
||||
res.status(400).json({ error: "nodeId must be a non-empty string or null" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await central.close();
|
||||
|
||||
res.json(project);
|
||||
res.json(resultProject);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
const status = err.message?.includes("not found") ? 404 : 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8053,6 +8068,223 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Node Management Routes (Multi-Node Support) ───────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/nodes
|
||||
* List all registered nodes.
|
||||
* Returns: NodeConfig[]
|
||||
*/
|
||||
router.get("/nodes", 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();
|
||||
|
||||
nodes.sort((a, b) => a.name.localeCompare(b.name));
|
||||
res.json(nodes);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/nodes
|
||||
* Register a new node.
|
||||
* Body: { name, type, url?, apiKey?, maxConcurrent?, capabilities? }
|
||||
*/
|
||||
router.post("/nodes", async (req, res) => {
|
||||
try {
|
||||
const { name, type, url, apiKey, maxConcurrent, capabilities } = req.body;
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
res.status(400).json({ error: "name is required and must be a non-empty string" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type !== "local" && type !== "remote") {
|
||||
res.status(400).json({ error: "type must be 'local' or 'remote'" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "remote" && (!url || typeof url !== "string" || !url.trim())) {
|
||||
res.status(400).json({ error: "url is required for remote nodes" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
maxConcurrent !== undefined
|
||||
&& (typeof maxConcurrent !== "number" || !Number.isFinite(maxConcurrent) || maxConcurrent < 1)
|
||||
) {
|
||||
res.status(400).json({ error: "maxConcurrent must be a number >= 1" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
capabilities !== undefined
|
||||
&& (!Array.isArray(capabilities) || capabilities.some((capability) => typeof capability !== "string"))
|
||||
) {
|
||||
res.status(400).json({ error: "capabilities must be an array of strings" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const node = await central.registerNode({
|
||||
name: name.trim(),
|
||||
type,
|
||||
url: typeof url === "string" ? url.trim() : undefined,
|
||||
apiKey: typeof apiKey === "string" ? apiKey : undefined,
|
||||
maxConcurrent,
|
||||
capabilities,
|
||||
});
|
||||
|
||||
await central.close();
|
||||
res.status(201).json(node);
|
||||
} catch (err: any) {
|
||||
const status = err.message?.includes("already exists") ? 409 : err.message?.includes("must") ? 400 : 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/nodes/:id
|
||||
* Get node details by ID.
|
||||
*/
|
||||
router.get("/nodes/:id", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const node = await central.getNode(req.params.id);
|
||||
await central.close();
|
||||
|
||||
if (!node) {
|
||||
res.status(404).json({ error: "Node not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(node);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/nodes/:id
|
||||
* Update node config.
|
||||
*/
|
||||
router.patch("/nodes/:id", async (req, res) => {
|
||||
try {
|
||||
const { name, url, apiKey, maxConcurrent, status, capabilities } = req.body;
|
||||
|
||||
const updates: Partial<Omit<import("@fusion/core").NodeConfig, "id" | "createdAt">> = {};
|
||||
if (name !== undefined) updates.name = name;
|
||||
if (url !== undefined) updates.url = url;
|
||||
if (apiKey !== undefined) updates.apiKey = apiKey;
|
||||
if (maxConcurrent !== undefined) updates.maxConcurrent = maxConcurrent;
|
||||
if (status !== undefined) updates.status = status as import("@fusion/core").NodeStatus;
|
||||
if (capabilities !== undefined) updates.capabilities = capabilities;
|
||||
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const node = await central.updateNode(req.params.id, updates);
|
||||
await central.close();
|
||||
|
||||
res.json(node);
|
||||
} catch (err: any) {
|
||||
const status = err.message?.includes("not found") ? 404 : err.message?.includes("must") ? 400 : 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/nodes/:id
|
||||
* Unregister a node.
|
||||
*/
|
||||
router.delete("/nodes/:id", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const existing = await central.getNode(req.params.id);
|
||||
if (!existing) {
|
||||
await central.close();
|
||||
res.status(404).json({ error: "Node not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await central.unregisterNode(req.params.id);
|
||||
await central.close();
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/nodes/:id/health-check
|
||||
* Trigger health check for a node.
|
||||
*/
|
||||
router.post("/nodes/:id/health-check", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const healthStatus = await central.checkNodeHealth(req.params.id);
|
||||
await central.close();
|
||||
|
||||
res.json({ status: healthStatus });
|
||||
} catch (err: any) {
|
||||
const status = err.message?.includes("not found") ? 404 : 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/nodes/:id/metrics
|
||||
* Get node runtime metrics.
|
||||
*/
|
||||
router.get("/nodes/:id/metrics", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const node = await central.getNode(req.params.id);
|
||||
await central.close();
|
||||
|
||||
if (!node) {
|
||||
res.status(404).json({ error: "Node not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.type === "local") {
|
||||
res.json({
|
||||
status: "online",
|
||||
activeTasks: 0,
|
||||
maxConcurrent: node.maxConcurrent,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ error: "Remote node metrics not yet implemented" });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/activity-feed
|
||||
* Get unified activity feed across all projects.
|
||||
|
||||
Reference in New Issue
Block a user