feat(FN-1081): add dashboard nodes management workflow
- Add node API typings and client methods plus a polling useNodes hook with visibility-triggered refresh - Introduce NodesView with NodeCard, AddNodeModal, and NodeDetailModal to register, inspect, update, health-check, and remove nodes - Wire the Nodes surface into the app shell/header and add project node assignment UI with project card node badges - Add unit coverage for useNodes, NodesView, NodeCard, Header, and ProjectCard behavior, plus node-specific dashboard styling
This commit is contained in:
233
packages/dashboard/app/hooks/__tests__/useNodes.test.ts
Normal file
233
packages/dashboard/app/hooks/__tests__/useNodes.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useNodes } from "../useNodes";
|
||||
import * as api from "../../api";
|
||||
import type { NodeInfo, NodeCreateInput } from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchNodes: vi.fn(),
|
||||
registerNode: vi.fn(),
|
||||
updateNode: vi.fn(),
|
||||
unregisterNode: vi.fn(),
|
||||
checkNodeHealth: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchNodes = vi.mocked(api.fetchNodes);
|
||||
const mockRegisterNode = vi.mocked(api.registerNode);
|
||||
const mockUpdateNode = vi.mocked(api.updateNode);
|
||||
const mockUnregisterNode = vi.mocked(api.unregisterNode);
|
||||
const mockCheckNodeHealth = vi.mocked(api.checkNodeHealth);
|
||||
|
||||
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
||||
return {
|
||||
id: "node_local",
|
||||
name: "Local Node",
|
||||
type: "local",
|
||||
status: "online",
|
||||
capabilities: ["executor"],
|
||||
maxConcurrent: 2,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("useNodes", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockFetchNodes.mockReset();
|
||||
mockRegisterNode.mockReset();
|
||||
mockUpdateNode.mockReset();
|
||||
mockUnregisterNode.mockReset();
|
||||
mockCheckNodeHealth.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("fetches nodes on mount", async () => {
|
||||
mockFetchNodes.mockResolvedValueOnce([makeNode()]);
|
||||
|
||||
const { result } = renderHook(() => useNodes());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.nodes).toHaveLength(1);
|
||||
expect(result.current.nodes[0].name).toBe("Local Node");
|
||||
});
|
||||
|
||||
it("handles fetch error gracefully", async () => {
|
||||
mockFetchNodes.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
const { result } = renderHook(() => useNodes());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBe("boom");
|
||||
});
|
||||
|
||||
it("register adds node optimistically", async () => {
|
||||
mockFetchNodes.mockResolvedValueOnce([]);
|
||||
const nodeInput: NodeCreateInput = { name: "Remote Node", type: "remote", url: "https://node.test" };
|
||||
const createdNode = makeNode({
|
||||
id: "node_remote",
|
||||
name: "Remote Node",
|
||||
type: "remote",
|
||||
url: "https://node.test",
|
||||
status: "connecting",
|
||||
});
|
||||
mockRegisterNode.mockResolvedValueOnce(createdNode);
|
||||
|
||||
const { result } = renderHook(() => useNodes());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.register(nodeInput);
|
||||
});
|
||||
|
||||
expect(mockRegisterNode).toHaveBeenCalledWith(nodeInput);
|
||||
expect(result.current.nodes).toHaveLength(1);
|
||||
expect(result.current.nodes[0].id).toBe("node_remote");
|
||||
});
|
||||
|
||||
it("update modifies node optimistically", async () => {
|
||||
mockFetchNodes.mockResolvedValueOnce([makeNode()]);
|
||||
const updatedNode = makeNode({ name: "Renamed Node", maxConcurrent: 4 });
|
||||
mockUpdateNode.mockResolvedValueOnce(updatedNode);
|
||||
|
||||
const { result } = renderHook(() => useNodes());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.update("node_local", { name: "Renamed Node", maxConcurrent: 4 });
|
||||
});
|
||||
|
||||
expect(mockUpdateNode).toHaveBeenCalledWith("node_local", { name: "Renamed Node", maxConcurrent: 4 });
|
||||
expect(result.current.nodes[0].name).toBe("Renamed Node");
|
||||
});
|
||||
|
||||
it("unregister removes node optimistically", async () => {
|
||||
mockFetchNodes.mockResolvedValueOnce([makeNode()]);
|
||||
mockUnregisterNode.mockResolvedValueOnce(undefined);
|
||||
|
||||
const { result } = renderHook(() => useNodes());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.nodes).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.unregister("node_local");
|
||||
});
|
||||
|
||||
expect(mockUnregisterNode).toHaveBeenCalledWith("node_local");
|
||||
expect(result.current.nodes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("healthCheck updates node status in local state", async () => {
|
||||
mockFetchNodes.mockResolvedValueOnce([makeNode({ status: "offline" })]);
|
||||
mockCheckNodeHealth.mockResolvedValueOnce({
|
||||
nodeId: "node_local",
|
||||
status: "online",
|
||||
checkedAt: "2026-01-03T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useNodes());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.nodes[0].status).toBe("offline");
|
||||
|
||||
await act(async () => {
|
||||
await result.current.healthCheck("node_local");
|
||||
});
|
||||
|
||||
expect(mockCheckNodeHealth).toHaveBeenCalledWith("node_local");
|
||||
expect(result.current.nodes[0].status).toBe("online");
|
||||
expect(result.current.nodes[0].updatedAt).toBe("2026-01-03T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("refresh manually refetches nodes", async () => {
|
||||
mockFetchNodes
|
||||
.mockResolvedValueOnce([makeNode({ name: "Before Refresh" })])
|
||||
.mockResolvedValueOnce([makeNode({ name: "After Refresh" })]);
|
||||
|
||||
const { result } = renderHook(() => useNodes());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.nodes[0].name).toBe("Before Refresh");
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(result.current.nodes[0].name).toBe("After Refresh");
|
||||
});
|
||||
|
||||
it("refetches when visibility changes back to visible", async () => {
|
||||
const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||
mockFetchNodes
|
||||
.mockResolvedValueOnce([makeNode({ name: "Initial" })])
|
||||
.mockResolvedValueOnce([makeNode({ name: "Visible Again" })]);
|
||||
|
||||
const { result } = renderHook(() => useNodes());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "hidden",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1100);
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.nodes[0].name).toBe("Visible Again");
|
||||
|
||||
if (originalVisibilityState) {
|
||||
Object.defineProperty(document, "visibilityState", originalVisibilityState);
|
||||
}
|
||||
});
|
||||
});
|
||||
149
packages/dashboard/app/hooks/useNodes.ts
Normal file
149
packages/dashboard/app/hooks/useNodes.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { NodeCreateInput, NodeInfo, NodeUpdateInput } from "../api";
|
||||
import {
|
||||
fetchNodes,
|
||||
registerNode,
|
||||
updateNode,
|
||||
unregisterNode,
|
||||
checkNodeHealth,
|
||||
} from "../api";
|
||||
|
||||
export interface UseNodesResult {
|
||||
nodes: NodeInfo[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
register: (input: NodeCreateInput) => Promise<NodeInfo>;
|
||||
update: (id: string, updates: NodeUpdateInput) => Promise<NodeInfo>;
|
||||
unregister: (id: string) => Promise<void>;
|
||||
healthCheck: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 10000; // 10 seconds
|
||||
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
|
||||
|
||||
/**
|
||||
* Hook for fetching and managing node registry state.
|
||||
* Automatically polls for updates every 10 seconds.
|
||||
* Refetches when the tab becomes visible again.
|
||||
*/
|
||||
export function useNodes(): UseNodesResult {
|
||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||
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 fetchNodes();
|
||||
setNodes(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch nodes");
|
||||
// Keep stale data visible if polling refresh fails
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchNodes();
|
||||
if (!cancelled) {
|
||||
setNodes(data);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch nodes");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceLastRefresh = now - lastVisibilityRefreshRef.current;
|
||||
if (timeSinceLastRefresh < 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]);
|
||||
|
||||
const register = useCallback(async (input: NodeCreateInput): Promise<NodeInfo> => {
|
||||
const node = await registerNode(input);
|
||||
setNodes((prev) => [...prev, node]);
|
||||
return node;
|
||||
}, []);
|
||||
|
||||
const update = useCallback(async (id: string, updates: NodeUpdateInput): Promise<NodeInfo> => {
|
||||
const node = await updateNode(id, updates);
|
||||
setNodes((prev) => prev.map((existing) => (existing.id === id ? node : existing)));
|
||||
return node;
|
||||
}, []);
|
||||
|
||||
const unregister = useCallback(async (id: string): Promise<void> => {
|
||||
await unregisterNode(id);
|
||||
setNodes((prev) => prev.filter((node) => node.id !== id));
|
||||
}, []);
|
||||
|
||||
const healthCheck = useCallback(async (id: string): Promise<void> => {
|
||||
const result = await checkNodeHealth(id);
|
||||
setNodes((prev) => prev.map((node) => (
|
||||
node.id === id
|
||||
? {
|
||||
...node,
|
||||
status: result.status,
|
||||
updatedAt: result.checkedAt,
|
||||
}
|
||||
: node
|
||||
)));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
nodes,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
register,
|
||||
update,
|
||||
unregister,
|
||||
healthCheck,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user