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,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");
});
});

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

View File

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

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

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

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