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:
gsxdsm
2026-04-07 23:30:32 -07:00
parent 74cae4f870
commit 075402c11d
17 changed files with 2218 additions and 32 deletions

View 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);
}
});
});

View 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,
};
}