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:
193
packages/dashboard/app/hooks/__tests__/useNodeProxy.test.ts
Normal file
193
packages/dashboard/app/hooks/__tests__/useNodeProxy.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useNodeProxy } from "../useNodeProxy";
|
||||
import * as apiModule from "../../api";
|
||||
import * as NodeContextModule from "../../context/NodeContext";
|
||||
import type { NodeConfig } from "@fusion/core";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
proxyApi: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../context/NodeContext", () => ({
|
||||
useNodeContext: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockProxyApi = vi.mocked(apiModule.proxyApi);
|
||||
const mockUseNodeContext = vi.mocked(NodeContextModule.useNodeContext);
|
||||
|
||||
describe("useNodeProxy", () => {
|
||||
beforeEach(() => {
|
||||
mockProxyApi.mockReset();
|
||||
mockUseNodeContext.mockReset();
|
||||
});
|
||||
|
||||
it("calls proxyApi with nodeId when remote node is set", async () => {
|
||||
const mockNode: NodeConfig = {
|
||||
id: "node_abc123",
|
||||
name: "Remote Node",
|
||||
type: "remote",
|
||||
url: "http://remote:4040",
|
||||
status: "online",
|
||||
maxConcurrent: 2,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
mockUseNodeContext.mockReturnValue({
|
||||
currentNode: mockNode,
|
||||
currentNodeId: "node_abc123",
|
||||
isRemote: true,
|
||||
setCurrentNode: vi.fn(),
|
||||
clearCurrentNode: vi.fn(),
|
||||
});
|
||||
|
||||
mockProxyApi.mockResolvedValueOnce({ tasks: [] });
|
||||
|
||||
const { result } = renderHook(() => useNodeProxy());
|
||||
|
||||
const response = await result.current.proxyFetch<{ tasks: unknown[] }>("/tasks");
|
||||
|
||||
expect(mockProxyApi).toHaveBeenCalledTimes(1);
|
||||
expect(mockProxyApi).toHaveBeenCalledWith("/tasks", {
|
||||
nodeId: "node_abc123",
|
||||
});
|
||||
expect(response).toEqual({ tasks: [] });
|
||||
});
|
||||
|
||||
it("calls proxyApi without nodeId when no node is set (local view)", async () => {
|
||||
mockUseNodeContext.mockReturnValue({
|
||||
currentNode: null,
|
||||
currentNodeId: null,
|
||||
isRemote: false,
|
||||
setCurrentNode: vi.fn(),
|
||||
clearCurrentNode: vi.fn(),
|
||||
});
|
||||
|
||||
mockProxyApi.mockResolvedValueOnce({ tasks: [] });
|
||||
|
||||
const { result } = renderHook(() => useNodeProxy());
|
||||
|
||||
const response = await result.current.proxyFetch<{ tasks: unknown[] }>("/tasks");
|
||||
|
||||
expect(mockProxyApi).toHaveBeenCalledTimes(1);
|
||||
expect(mockProxyApi).toHaveBeenCalledWith("/tasks", {
|
||||
nodeId: undefined,
|
||||
});
|
||||
expect(response).toEqual({ tasks: [] });
|
||||
});
|
||||
|
||||
it("calls proxyApi without nodeId when node is local type", async () => {
|
||||
const mockLocalNode: NodeConfig = {
|
||||
id: "node_local",
|
||||
name: "Local Node",
|
||||
type: "local",
|
||||
status: "online",
|
||||
maxConcurrent: 4,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
mockUseNodeContext.mockReturnValue({
|
||||
currentNode: mockLocalNode,
|
||||
currentNodeId: "node_local",
|
||||
isRemote: false,
|
||||
setCurrentNode: vi.fn(),
|
||||
clearCurrentNode: vi.fn(),
|
||||
});
|
||||
|
||||
mockProxyApi.mockResolvedValueOnce({ tasks: [] });
|
||||
|
||||
const { result } = renderHook(() => useNodeProxy());
|
||||
|
||||
const response = await result.current.proxyFetch<{ tasks: unknown[] }>("/tasks");
|
||||
|
||||
expect(mockProxyApi).toHaveBeenCalledTimes(1);
|
||||
expect(mockProxyApi).toHaveBeenCalledWith("/tasks", {
|
||||
nodeId: undefined,
|
||||
});
|
||||
expect(response).toEqual({ tasks: [] });
|
||||
});
|
||||
|
||||
it("returns currentNodeId from context", async () => {
|
||||
const mockNode: NodeConfig = {
|
||||
id: "node_xyz",
|
||||
name: "Remote Node",
|
||||
type: "remote",
|
||||
url: "http://remote:4040",
|
||||
status: "online",
|
||||
maxConcurrent: 2,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
mockUseNodeContext.mockReturnValue({
|
||||
currentNode: mockNode,
|
||||
currentNodeId: "node_xyz",
|
||||
isRemote: true,
|
||||
setCurrentNode: vi.fn(),
|
||||
clearCurrentNode: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useNodeProxy());
|
||||
|
||||
expect(result.current.currentNodeId).toBe("node_xyz");
|
||||
expect(result.current.isRemote).toBe(true);
|
||||
});
|
||||
|
||||
it("returns isRemote false when no node is set", async () => {
|
||||
mockUseNodeContext.mockReturnValue({
|
||||
currentNode: null,
|
||||
currentNodeId: null,
|
||||
isRemote: false,
|
||||
setCurrentNode: vi.fn(),
|
||||
clearCurrentNode: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useNodeProxy());
|
||||
|
||||
expect(result.current.currentNodeId).toBe(null);
|
||||
expect(result.current.isRemote).toBe(false);
|
||||
});
|
||||
|
||||
it("passes through RequestInit options", async () => {
|
||||
mockUseNodeContext.mockReturnValue({
|
||||
currentNode: null,
|
||||
currentNodeId: null,
|
||||
isRemote: false,
|
||||
setCurrentNode: vi.fn(),
|
||||
clearCurrentNode: vi.fn(),
|
||||
});
|
||||
|
||||
mockProxyApi.mockResolvedValueOnce(undefined);
|
||||
|
||||
const { result } = renderHook(() => useNodeProxy());
|
||||
|
||||
await result.current.proxyFetch("/tasks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title: "New Task" }),
|
||||
});
|
||||
|
||||
expect(mockProxyApi).toHaveBeenCalledWith("/tasks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title: "New Task" }),
|
||||
nodeId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates errors from proxyApi", async () => {
|
||||
mockUseNodeContext.mockReturnValue({
|
||||
currentNode: null,
|
||||
currentNodeId: null,
|
||||
isRemote: false,
|
||||
setCurrentNode: vi.fn(),
|
||||
clearCurrentNode: vi.fn(),
|
||||
});
|
||||
|
||||
mockProxyApi.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const { result } = renderHook(() => useNodeProxy());
|
||||
|
||||
await expect(result.current.proxyFetch("/tasks")).rejects.toThrow("Network error");
|
||||
});
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useRemoteNodeEvents } from "../useRemoteNodeEvents";
|
||||
|
||||
describe("useRemoteNodeEvents", () => {
|
||||
let mockEventSource: {
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
onopen: ((...args: unknown[]) => void) | null;
|
||||
onerror: ((...args: unknown[]) => void) | null;
|
||||
addEventListener: ReturnType<typeof vi.fn>;
|
||||
removeEventListener: ReturnType<typeof vi.fn>;
|
||||
readyState: number;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Create mock EventSource
|
||||
mockEventSource = {
|
||||
close: vi.fn(),
|
||||
onopen: null,
|
||||
onerror: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
readyState: 1, // CONNECTING
|
||||
};
|
||||
|
||||
// Mock global EventSource constructor
|
||||
vi.stubGlobal("EventSource", vi.fn().mockImplementation(() => mockEventSource));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("when nodeId is null", () => {
|
||||
it("returns disconnected state without creating EventSource", () => {
|
||||
const { result } = renderHook(() => useRemoteNodeEvents(null));
|
||||
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(result.current.lastEvent).toBe(null);
|
||||
expect(vi.mocked(EventSource)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns disconnected state with null nodeId even after timer advances", () => {
|
||||
const { result } = renderHook(() => useRemoteNodeEvents(null));
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(result.current.lastEvent).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when nodeId is provided", () => {
|
||||
it("creates EventSource connected to proxy SSE endpoint", () => {
|
||||
renderHook(() => useRemoteNodeEvents("node_abc"));
|
||||
|
||||
expect(vi.mocked(EventSource)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(EventSource)).toHaveBeenCalledWith("/api/proxy/node_abc/events");
|
||||
});
|
||||
|
||||
it("properly encodes nodeId with special characters", () => {
|
||||
renderHook(() => useRemoteNodeEvents("node/abc+test"));
|
||||
|
||||
expect(vi.mocked(EventSource)).toHaveBeenCalledWith("/api/proxy/node%2Fabc%2Btest/events");
|
||||
});
|
||||
|
||||
it("returns disconnected initially until onopen fires", () => {
|
||||
const { result } = renderHook(() => useRemoteNodeEvents("node_abc"));
|
||||
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(result.current.lastEvent).toBe(null);
|
||||
|
||||
// Simulate connection open
|
||||
act(() => {
|
||||
mockEventSource.onopen?.({});
|
||||
});
|
||||
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it("stores last event when task:created event is received", () => {
|
||||
const { result } = renderHook(() => useRemoteNodeEvents("node_abc"));
|
||||
|
||||
act(() => {
|
||||
mockEventSource.onopen?.({});
|
||||
});
|
||||
|
||||
// Simulate task:created event
|
||||
const taskCreatedHandler = vi.mocked(mockEventSource.addEventListener).mock.calls.find(
|
||||
(call) => call[0] === "task:created",
|
||||
)?.[1] as (event: MessageEvent) => void;
|
||||
|
||||
const mockEvent = { data: '{"id":"FN-001","title":"Test"}' } as MessageEvent;
|
||||
act(() => {
|
||||
taskCreatedHandler?.(mockEvent);
|
||||
});
|
||||
|
||||
expect(result.current.lastEvent).toEqual({
|
||||
type: "task:created",
|
||||
data: '{"id":"FN-001","title":"Test"}',
|
||||
});
|
||||
});
|
||||
|
||||
it("stores last event for each event type", () => {
|
||||
const { result } = renderHook(() => useRemoteNodeEvents("node_abc"));
|
||||
|
||||
act(() => {
|
||||
mockEventSource.onopen?.({});
|
||||
});
|
||||
|
||||
// Test task:moved
|
||||
const movedHandler = vi.mocked(mockEventSource.addEventListener).mock.calls.find(
|
||||
(call) => call[0] === "task:moved",
|
||||
)?.[1] as (event: MessageEvent) => void;
|
||||
act(() => {
|
||||
movedHandler?.({ data: '{"task":"FN-001","to":"in-progress"}' } as MessageEvent);
|
||||
});
|
||||
expect(result.current.lastEvent?.type).toBe("task:moved");
|
||||
|
||||
// Test task:updated
|
||||
const updatedHandler = vi.mocked(mockEventSource.addEventListener).mock.calls.find(
|
||||
(call) => call[0] === "task:updated",
|
||||
)?.[1] as (event: MessageEvent) => void;
|
||||
act(() => {
|
||||
updatedHandler?.({ data: '{"id":"FN-001","title":"Updated"}' } as MessageEvent);
|
||||
});
|
||||
expect(result.current.lastEvent?.type).toBe("task:updated");
|
||||
|
||||
// Test task:deleted
|
||||
const deletedHandler = vi.mocked(mockEventSource.addEventListener).mock.calls.find(
|
||||
(call) => call[0] === "task:deleted",
|
||||
)?.[1] as (event: MessageEvent) => void;
|
||||
act(() => {
|
||||
deletedHandler?.({ data: '{"id":"FN-001"}' } as MessageEvent);
|
||||
});
|
||||
expect(result.current.lastEvent?.type).toBe("task:deleted");
|
||||
|
||||
// Test task:merged
|
||||
const mergedHandler = vi.mocked(mockEventSource.addEventListener).mock.calls.find(
|
||||
(call) => call[0] === "task:merged",
|
||||
)?.[1] as (event: MessageEvent) => void;
|
||||
act(() => {
|
||||
mergedHandler?.({ data: '{"id":"FN-001"}' } as MessageEvent);
|
||||
});
|
||||
expect(result.current.lastEvent?.type).toBe("task:merged");
|
||||
});
|
||||
|
||||
it("closes EventSource on unmount", () => {
|
||||
const { unmount } = renderHook(() => useRemoteNodeEvents("node_abc"));
|
||||
|
||||
act(() => {
|
||||
mockEventSource.onopen?.({});
|
||||
});
|
||||
|
||||
expect(mockEventSource.close).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockEventSource.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes EventSource and reconnects on error", () => {
|
||||
const { result } = renderHook(() => useRemoteNodeEvents("node_abc"));
|
||||
|
||||
act(() => {
|
||||
mockEventSource.onopen?.({});
|
||||
});
|
||||
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
|
||||
// Simulate error
|
||||
act(() => {
|
||||
mockEventSource.onerror?.({});
|
||||
});
|
||||
|
||||
expect(mockEventSource.close).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
|
||||
// Advance timer to trigger reconnect
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
// Should have created a new EventSource
|
||||
expect(vi.mocked(EventSource)).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("cleans up heartbeat timer on unmount", () => {
|
||||
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
|
||||
|
||||
const { unmount } = renderHook(() => useRemoteNodeEvents("node_abc"));
|
||||
|
||||
act(() => {
|
||||
mockEventSource.onopen?.({});
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes previous EventSource when nodeId changes", () => {
|
||||
const { rerender } = renderHook(
|
||||
({ nodeId }: { nodeId: string | null }) => useRemoteNodeEvents(nodeId),
|
||||
{ initialProps: { nodeId: "node_abc" } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
mockEventSource.onopen?.({});
|
||||
});
|
||||
|
||||
expect(mockEventSource.close).not.toHaveBeenCalled();
|
||||
|
||||
// Change nodeId
|
||||
rerender({ nodeId: "node_xyz" });
|
||||
|
||||
expect(mockEventSource.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes EventSource on unmount", () => {
|
||||
const { unmount } = renderHook(() => useRemoteNodeEvents("node_abc"));
|
||||
|
||||
act(() => {
|
||||
mockEventSource.onopen?.({});
|
||||
});
|
||||
|
||||
expect(mockEventSource.close).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockEventSource.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconnection timing", () => {
|
||||
it("reconnects after RECONNECT_DELAY_MS (3000)", () => {
|
||||
renderHook(() => useRemoteNodeEvents("node_abc"));
|
||||
|
||||
act(() => {
|
||||
mockEventSource.onopen?.({});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
mockEventSource.onerror?.({});
|
||||
});
|
||||
|
||||
expect(result => {
|
||||
vi.mocked(EventSource).mock.calls.length === 1;
|
||||
});
|
||||
|
||||
// Advance time but not enough for reconnect
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
// Should not have reconnected yet
|
||||
expect(vi.mocked(EventSource)).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance remaining time
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
// Should have reconnected
|
||||
expect(vi.mocked(EventSource)).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
47
packages/dashboard/app/hooks/useNodeProxy.ts
Normal file
47
packages/dashboard/app/hooks/useNodeProxy.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* useNodeProxy hook - provides a proxy-aware fetch function that routes API calls
|
||||
* through the node proxy when viewing a remote node.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { proxyApi } from "../api";
|
||||
import { useNodeContext } from "../context/NodeContext";
|
||||
|
||||
export interface UseNodeProxyResult {
|
||||
/**
|
||||
* Make an API request, optionally routing through the node proxy for remote nodes.
|
||||
* When a remote node is active, requests are routed through /api/proxy/:nodeId/...
|
||||
*/
|
||||
proxyFetch: <T>(path: string, opts?: RequestInit) => Promise<T>;
|
||||
/** The current node ID or null if viewing local node */
|
||||
currentNodeId: string | null;
|
||||
/** Whether the current view is a remote node */
|
||||
isRemote: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that provides proxy-aware API fetching.
|
||||
* Returns a proxyFetch function that automatically routes requests through the
|
||||
* node proxy when viewing a remote node.
|
||||
*/
|
||||
export function useNodeProxy(): UseNodeProxyResult {
|
||||
const { currentNodeId, isRemote } = useNodeContext();
|
||||
|
||||
const proxyFetch = useCallback(
|
||||
<T>(path: string, opts?: RequestInit): Promise<T> => {
|
||||
// Only route through proxy when viewing a remote node
|
||||
// When local or no node set, proxyApi will call the direct API
|
||||
return proxyApi<T>(path, {
|
||||
...opts,
|
||||
nodeId: isRemote ? currentNodeId ?? undefined : undefined,
|
||||
});
|
||||
},
|
||||
[currentNodeId, isRemote],
|
||||
);
|
||||
|
||||
return {
|
||||
proxyFetch,
|
||||
currentNodeId,
|
||||
isRemote,
|
||||
};
|
||||
}
|
||||
158
packages/dashboard/app/hooks/useRemoteNodeData.ts
Normal file
158
packages/dashboard/app/hooks/useRemoteNodeData.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* useRemoteNodeData hook - fetches projects, tasks, and health data from a remote node.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import type { ProjectInfo } from "../api";
|
||||
import {
|
||||
fetchRemoteNodeHealth,
|
||||
fetchRemoteNodeProjects,
|
||||
fetchRemoteNodeTasks,
|
||||
fetchRemoteNodeProjectHealth,
|
||||
type RemoteNodeHealth,
|
||||
} from "../api-node";
|
||||
|
||||
export interface UseRemoteNodeDataOptions {
|
||||
/** Project ID to fetch tasks for */
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface UseRemoteNodeDataResult {
|
||||
/** Projects from the remote node */
|
||||
projects: ProjectInfo[];
|
||||
/** Tasks from the remote node (if projectId is provided) */
|
||||
tasks: Task[];
|
||||
/** Health information from the remote node */
|
||||
health: RemoteNodeHealth | null;
|
||||
/** Whether data is currently being fetched */
|
||||
loading: boolean;
|
||||
/** Error message if fetch failed */
|
||||
error: string | null;
|
||||
/** Manually refresh all data */
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching data from a remote node.
|
||||
* Fetches health and projects on mount (and when nodeId changes).
|
||||
* If projectId is provided, also fetches tasks and project health.
|
||||
*/
|
||||
export function useRemoteNodeData(
|
||||
nodeId: string | null,
|
||||
options?: UseRemoteNodeDataOptions,
|
||||
): UseRemoteNodeDataResult {
|
||||
const { projectId } = options ?? {};
|
||||
|
||||
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [health, setHealth] = useState<RemoteNodeHealth | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Track in-flight requests for cleanup
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
// No nodeId means no fetching needed
|
||||
if (!nodeId) {
|
||||
setProjects([]);
|
||||
setTasks([]);
|
||||
setHealth(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any in-flight requests
|
||||
abortControllerRef.current?.abort();
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Fetch health and projects in parallel
|
||||
const promises: Promise<unknown>[] = [
|
||||
fetchRemoteNodeHealth(nodeId),
|
||||
fetchRemoteNodeProjects(nodeId),
|
||||
];
|
||||
|
||||
// Add tasks and project health fetches if projectId is provided
|
||||
if (projectId) {
|
||||
promises.push(fetchRemoteNodeTasks(nodeId, projectId));
|
||||
promises.push(fetchRemoteNodeProjectHealth(nodeId, projectId));
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
|
||||
// Check if aborted
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process results - type-safe access
|
||||
const healthResult = results[0];
|
||||
const projectsResult = results[1];
|
||||
|
||||
if (healthResult.status === "rejected") {
|
||||
setError(`Failed to fetch node health: ${healthResult.reason}`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setHealth(healthResult.value as RemoteNodeHealth);
|
||||
|
||||
if (projectsResult.status === "rejected") {
|
||||
setError(`Failed to fetch projects: ${projectsResult.reason}`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setProjects(projectsResult.value as ProjectInfo[]);
|
||||
|
||||
// Process optional results - tasks are at index 2
|
||||
if (projectId && results[2]) {
|
||||
const tasksResult = results[2];
|
||||
if (tasksResult.status === "rejected") {
|
||||
setError(`Failed to fetch tasks: ${tasksResult.reason}`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (tasksResult.status === "fulfilled") {
|
||||
setTasks(tasksResult.value as Task[]);
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
if (!abortController.signal.aborted) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [nodeId, projectId]);
|
||||
|
||||
// Fetch on mount and when nodeId changes
|
||||
useEffect(() => {
|
||||
void fetchData();
|
||||
|
||||
// Cleanup: abort in-flight requests on unmount or when dependencies change
|
||||
return () => {
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
// Refresh function for manual re-fetch
|
||||
const refresh = useCallback(() => {
|
||||
void fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return {
|
||||
projects,
|
||||
tasks,
|
||||
health,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
193
packages/dashboard/app/hooks/useRemoteNodeEvents.ts
Normal file
193
packages/dashboard/app/hooks/useRemoteNodeEvents.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* useRemoteNodeEvents hook - subscribes to SSE events from a remote node via the proxy.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const RECONNECT_DELAY_MS = 3000;
|
||||
/** If no SSE message (including heartbeat events) arrives within this window, force reconnect. */
|
||||
const HEARTBEAT_TIMEOUT_MS = 45_000;
|
||||
|
||||
export interface RemoteNodeEvent {
|
||||
type: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface UseRemoteNodeEventsResult {
|
||||
/** Whether the SSE connection is currently active */
|
||||
isConnected: boolean;
|
||||
/** The last received event, or null if no events received */
|
||||
lastEvent: RemoteNodeEvent | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for subscribing to SSE events from a remote node via the proxy.
|
||||
* Opens an EventSource to /api/proxy/:nodeId/events and listens for task events.
|
||||
* Implements reconnection logic and heartbeat timeout detection.
|
||||
*/
|
||||
export function useRemoteNodeEvents(nodeId: string | null): UseRemoteNodeEventsResult {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [lastEvent, setLastEvent] = useState<RemoteNodeEvent | null>(null);
|
||||
const [connectionNonce, setConnectionNonce] = useState(0);
|
||||
|
||||
// Refs for cleanup
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const heartbeatTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Reset heartbeat watchdog on each message
|
||||
const resetHeartbeat = useCallback(() => {
|
||||
if (heartbeatTimerRef.current) {
|
||||
clearTimeout(heartbeatTimerRef.current);
|
||||
}
|
||||
heartbeatTimerRef.current = setTimeout(() => {
|
||||
// No message received within the timeout — connection is likely dead
|
||||
handleConnectionError();
|
||||
}, HEARTBEAT_TIMEOUT_MS);
|
||||
}, []);
|
||||
|
||||
// Handle connection errors and schedule reconnect
|
||||
const handleConnectionError = useCallback(() => {
|
||||
// Clean up existing connection
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
if (heartbeatTimerRef.current) {
|
||||
clearTimeout(heartbeatTimerRef.current);
|
||||
heartbeatTimerRef.current = null;
|
||||
}
|
||||
|
||||
setIsConnected(false);
|
||||
|
||||
// Schedule reconnect
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
}
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
reconnectTimerRef.current = null;
|
||||
setConnectionNonce((n) => n + 1);
|
||||
}, RECONNECT_DELAY_MS);
|
||||
}, []);
|
||||
|
||||
// Set up EventSource connection
|
||||
useEffect(() => {
|
||||
// No nodeId means no connection needed
|
||||
if (!nodeId) {
|
||||
setIsConnected(false);
|
||||
setLastEvent(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up any existing connection
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
if (heartbeatTimerRef.current) {
|
||||
clearTimeout(heartbeatTimerRef.current);
|
||||
heartbeatTimerRef.current = null;
|
||||
}
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Build SSE URL
|
||||
const encodedNodeId = encodeURIComponent(nodeId);
|
||||
const esUrl = `/api/proxy/${encodedNodeId}/events`;
|
||||
const eventSource = new EventSource(esUrl);
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
// Start heartbeat watchdog
|
||||
resetHeartbeat();
|
||||
|
||||
// Handle open event
|
||||
eventSource.onopen = () => {
|
||||
setIsConnected(true);
|
||||
resetHeartbeat();
|
||||
};
|
||||
|
||||
// Handle task:created events
|
||||
eventSource.addEventListener("task:created", (event: Event) => {
|
||||
resetHeartbeat();
|
||||
const messageEvent = event as MessageEvent;
|
||||
setLastEvent({
|
||||
type: "task:created",
|
||||
data: messageEvent.data,
|
||||
});
|
||||
});
|
||||
|
||||
// Handle task:moved events
|
||||
eventSource.addEventListener("task:moved", (event: Event) => {
|
||||
resetHeartbeat();
|
||||
const messageEvent = event as MessageEvent;
|
||||
setLastEvent({
|
||||
type: "task:moved",
|
||||
data: messageEvent.data,
|
||||
});
|
||||
});
|
||||
|
||||
// Handle task:updated events
|
||||
eventSource.addEventListener("task:updated", (event: Event) => {
|
||||
resetHeartbeat();
|
||||
const messageEvent = event as MessageEvent;
|
||||
setLastEvent({
|
||||
type: "task:updated",
|
||||
data: messageEvent.data,
|
||||
});
|
||||
});
|
||||
|
||||
// Handle task:deleted events
|
||||
eventSource.addEventListener("task:deleted", (event: Event) => {
|
||||
resetHeartbeat();
|
||||
const messageEvent = event as MessageEvent;
|
||||
setLastEvent({
|
||||
type: "task:deleted",
|
||||
data: messageEvent.data,
|
||||
});
|
||||
});
|
||||
|
||||
// Handle task:merged events
|
||||
eventSource.addEventListener("task:merged", (event: Event) => {
|
||||
resetHeartbeat();
|
||||
const messageEvent = event as MessageEvent;
|
||||
setLastEvent({
|
||||
type: "task:merged",
|
||||
data: messageEvent.data,
|
||||
});
|
||||
});
|
||||
|
||||
// Handle heartbeat events (named event type)
|
||||
eventSource.addEventListener("heartbeat", () => {
|
||||
resetHeartbeat();
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
eventSource.onerror = () => {
|
||||
handleConnectionError();
|
||||
};
|
||||
|
||||
// Cleanup on unmount or when nodeId changes
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
if (heartbeatTimerRef.current) {
|
||||
clearTimeout(heartbeatTimerRef.current);
|
||||
heartbeatTimerRef.current = null;
|
||||
}
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
setIsConnected(false);
|
||||
};
|
||||
}, [nodeId, connectionNonce, handleConnectionError, resetHeartbeat]);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
lastEvent,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user