feat(FN-1232): add remote node mesh dashboard integration

- Add core proxy API infrastructure (api-node.ts) with remote node communication
- Create NodeContext for centralized node state management
- Implement useNodeProxy hook for node operations (metrics, events, health)
- Implement useRemoteNodeData hook with 10-second polling for live metrics
- Implement useRemoteNodeEvents hook for real-time SSE event subscription
- Add NodeStatusIndicator component with animated status indicators
- Add comprehensive tests for all new hooks and components
- Update dashboard styles for node status visualization
This commit is contained in:
gsxdsm
2026-04-09 13:01:19 -07:00
parent 7ab96cc876
commit fc23e522a7
13 changed files with 1771 additions and 0 deletions

View File

@@ -0,0 +1,266 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useRemoteNodeData } from "../useRemoteNodeData";
import * as apiNodeModule from "../../api-node";
vi.mock("../../api-node", () => ({
fetchRemoteNodeHealth: vi.fn(),
fetchRemoteNodeProjects: vi.fn(),
fetchRemoteNodeTasks: vi.fn(),
fetchRemoteNodeProjectHealth: vi.fn(),
}));
const mockFetchRemoteNodeHealth = vi.mocked(apiNodeModule.fetchRemoteNodeHealth);
const mockFetchRemoteNodeProjects = vi.mocked(apiNodeModule.fetchRemoteNodeProjects);
const mockFetchRemoteNodeTasks = vi.mocked(apiNodeModule.fetchRemoteNodeTasks);
const mockFetchRemoteNodeProjectHealth = vi.mocked(apiNodeModule.fetchRemoteNodeProjectHealth);
describe("useRemoteNodeData", () => {
beforeEach(() => {
mockFetchRemoteNodeHealth.mockReset();
mockFetchRemoteNodeProjects.mockReset();
mockFetchRemoteNodeTasks.mockReset();
mockFetchRemoteNodeProjectHealth.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("when nodeId is null", () => {
it("returns empty state without fetching", () => {
const { result } = renderHook(() => useRemoteNodeData(null));
expect(result.current.projects).toEqual([]);
expect(result.current.tasks).toEqual([]);
expect(result.current.health).toBe(null);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
// No API calls should have been made
expect(mockFetchRemoteNodeHealth).not.toHaveBeenCalled();
expect(mockFetchRemoteNodeProjects).not.toHaveBeenCalled();
});
it("returns empty state with projectId option but no nodeId", () => {
const { result } = renderHook(() => useRemoteNodeData(null, { projectId: "proj_001" }));
expect(result.current.projects).toEqual([]);
expect(result.current.tasks).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
// No API calls should have been made
expect(mockFetchRemoteNodeHealth).not.toHaveBeenCalled();
expect(mockFetchRemoteNodeTasks).not.toHaveBeenCalled();
});
});
describe("when nodeId is provided", () => {
it("fetches health and projects on mount", async () => {
const mockHealth = { status: "online", version: "1.0.0", nodeId: "node_abc" };
const mockProjects = [
{
id: "proj_001",
name: "Test Project",
path: "/test/path",
status: "active" as const,
isolationMode: "in-process" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchRemoteNodeHealth.mockResolvedValueOnce(mockHealth);
mockFetchRemoteNodeProjects.mockResolvedValueOnce(mockProjects);
const { result } = renderHook(() => useRemoteNodeData("node_abc"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeHealth).toHaveBeenCalledTimes(1);
expect(mockFetchRemoteNodeHealth).toHaveBeenCalledWith("node_abc");
expect(mockFetchRemoteNodeProjects).toHaveBeenCalledTimes(1);
expect(mockFetchRemoteNodeProjects).toHaveBeenCalledWith("node_abc");
expect(result.current.health).toEqual(mockHealth);
expect(result.current.projects).toEqual(mockProjects);
});
it("fetches tasks and project health when projectId option is provided", async () => {
const mockHealth = { status: "online", version: "1.0.0", nodeId: "node_abc" };
const mockProjects = [
{
id: "proj_001",
name: "Test Project",
path: "/test/path",
status: "active" as const,
isolationMode: "in-process" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
const mockTasks = [
{
id: "FN-001",
title: "Test Task",
description: "Test description",
column: "todo" as const,
dependencies: [],
steps: [],
currentStep: 0,
size: "M" as const,
reviewLevel: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
},
];
const mockProjectHealth = {
activeTaskCount: 5,
inFlightAgentCount: 2,
status: "active" as const,
};
mockFetchRemoteNodeHealth.mockResolvedValueOnce(mockHealth);
mockFetchRemoteNodeProjects.mockResolvedValueOnce(mockProjects);
mockFetchRemoteNodeTasks.mockResolvedValueOnce(mockTasks);
mockFetchRemoteNodeProjectHealth.mockResolvedValueOnce(mockProjectHealth);
const { result } = renderHook(() =>
useRemoteNodeData("node_abc", { projectId: "proj_001" }),
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledTimes(1);
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledWith("node_abc", "proj_001");
expect(mockFetchRemoteNodeProjectHealth).toHaveBeenCalledTimes(1);
expect(mockFetchRemoteNodeProjectHealth).toHaveBeenCalledWith("node_abc", "proj_001");
expect(result.current.tasks).toEqual(mockTasks);
});
it("handles errors gracefully", async () => {
mockFetchRemoteNodeHealth.mockResolvedValueOnce({
status: "online",
version: "1.0.0",
nodeId: "node_abc",
});
mockFetchRemoteNodeProjects.mockRejectedValueOnce(new Error("Failed to fetch projects"));
const { result } = renderHook(() => useRemoteNodeData("node_abc"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toContain("Failed to fetch projects");
});
it("handles health fetch errors", async () => {
mockFetchRemoteNodeHealth.mockRejectedValueOnce(new Error("Health check failed"));
const { result } = renderHook(() => useRemoteNodeData("node_abc"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toContain("Health check failed");
});
it("refresh function re-fetches data", async () => {
const initialHealth = { status: "online", version: "1.0.0", nodeId: "node_abc" };
const initialProjects = [
{
id: "proj_001",
name: "Test Project",
path: "/test/path",
status: "active" as const,
isolationMode: "in-process" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchRemoteNodeHealth.mockResolvedValueOnce(initialHealth);
mockFetchRemoteNodeProjects.mockResolvedValueOnce(initialProjects);
const { result } = renderHook(() => useRemoteNodeData("node_abc"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.health).toEqual(initialHealth);
// Set up new responses for refresh
const refreshedHealth = { status: "online", version: "1.1.0", nodeId: "node_abc" };
const refreshedProjects = [
{
id: "proj_002",
name: "New Project",
path: "/new/path",
status: "active" as const,
isolationMode: "in-process" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchRemoteNodeHealth.mockResolvedValueOnce(refreshedHealth);
mockFetchRemoteNodeProjects.mockResolvedValueOnce(refreshedProjects);
// Call refresh
result.current.refresh();
await waitFor(() => {
expect(result.current.health).toEqual(refreshedHealth);
});
expect(result.current.projects).toEqual(refreshedProjects);
});
it("refetches when nodeId changes", async () => {
const mockHealth = { status: "online", version: "1.0.0", nodeId: "node_abc" };
const mockProjects = [
{
id: "proj_001",
name: "Test Project",
path: "/test/path",
status: "active" as const,
isolationMode: "in-process" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchRemoteNodeHealth.mockResolvedValue(mockHealth);
mockFetchRemoteNodeProjects.mockResolvedValue(mockProjects);
const { result, rerender } = renderHook(
({ nodeId }: { nodeId: string | null }) => useRemoteNodeData(nodeId),
{ initialProps: { nodeId: "node_abc" } },
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeHealth).toHaveBeenCalledTimes(1);
// Change nodeId
rerender({ nodeId: "node_xyz" });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Should have fetched for the new nodeId
expect(mockFetchRemoteNodeHealth).toHaveBeenCalledTimes(2);
expect(mockFetchRemoteNodeHealth).toHaveBeenLastCalledWith("node_xyz");
});
});
});