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:
266
packages/dashboard/app/hooks/__tests__/useRemoteNodeData.test.ts
Normal file
266
packages/dashboard/app/hooks/__tests__/useRemoteNodeData.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user