feat(FN-1408): add node provider and remote node status integration
- Add NodeProvider and useNodeContext to App shell for centralized node state - Add node selector dropdown and status indicator to Header component - Wire remote node data and event hooks into task-facing views - Update App.test.tsx mocks for new node context providers - Add project memory learnings for node context wiring patterns - Update dashboard guide documentation for node selection
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Server, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail } from "lucide-react";
|
||||
import type { ProjectInfo } from "../api";
|
||||
import type { NodeConfig } from "@fusion/core";
|
||||
import { fetchScripts } from "../api";
|
||||
import { ProjectSelector } from "./ProjectSelector";
|
||||
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";
|
||||
import { NodeStatusIndicator } from "./NodeStatusIndicator";
|
||||
import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
|
||||
|
||||
export { useViewportMode };
|
||||
@@ -64,6 +66,14 @@ export interface HeaderProps {
|
||||
isElectron?: boolean;
|
||||
/** When true, the mobile bottom nav bar handles primary navigation and header nav controls are hidden. */
|
||||
mobileNavEnabled?: boolean;
|
||||
/** Available nodes for the node selector */
|
||||
availableNodes?: NodeConfig[];
|
||||
/** Currently selected node (null for local) */
|
||||
currentNode?: NodeConfig | null;
|
||||
/** Callback when a node is selected */
|
||||
onSelectNode?: (node: NodeConfig | null) => void;
|
||||
/** Whether the current view is a remote node */
|
||||
isRemote?: boolean;
|
||||
}
|
||||
|
||||
export function Header({
|
||||
@@ -100,6 +110,10 @@ export function Header({
|
||||
projectId,
|
||||
isElectron = false,
|
||||
mobileNavEnabled,
|
||||
availableNodes = [],
|
||||
currentNode,
|
||||
onSelectNode,
|
||||
isRemote = false,
|
||||
}: HeaderProps) {
|
||||
const mode: ViewportMode = useViewportMode();
|
||||
const isMobile = mode === "mobile";
|
||||
@@ -112,6 +126,7 @@ export function Header({
|
||||
const [isNonMobileSearchExplicitlyClosed, setIsNonMobileSearchExplicitlyClosed] = useState(false);
|
||||
const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false);
|
||||
const [isTerminalSubmenuOpen, setIsTerminalSubmenuOpen] = useState(false);
|
||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||
const [overflowScripts, setOverflowScripts] = useState<Record<string, string>>({});
|
||||
const [overflowScriptsLoading, setOverflowScriptsLoading] = useState(false);
|
||||
const overflowButtonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -119,6 +134,14 @@ export function Header({
|
||||
const mobileSearchRef = useRef<HTMLDivElement>(null);
|
||||
const mobileSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
const terminalSubmenuOpenRef = useRef(false);
|
||||
const nodeSelectorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get remote nodes only (exclude local node type)
|
||||
const remoteNodes = useMemo(() =>
|
||||
availableNodes.filter((node) => node.type === "remote"),
|
||||
[availableNodes]
|
||||
);
|
||||
const showNodeSelector = remoteNodes.length > 0;
|
||||
|
||||
// Script entries sorted alphabetically for overflow submenu
|
||||
const overflowScriptEntries = useMemo(() => {
|
||||
@@ -194,6 +217,23 @@ export function Header({
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOverflowMenuOpen]);
|
||||
|
||||
// Close node selector on outside click
|
||||
useEffect(() => {
|
||||
if (!isNodeSelectorOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
nodeSelectorRef.current &&
|
||||
!nodeSelectorRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsNodeSelectorOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isNodeSelectorOpen]);
|
||||
|
||||
// Close menus on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -204,6 +244,7 @@ export function Header({
|
||||
}
|
||||
setIsOverflowMenuOpen(false);
|
||||
setIsMobileSearchOpen(false);
|
||||
setIsNodeSelectorOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -309,6 +350,69 @@ export function Header({
|
||||
<span>Projects</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Node selector and status indicator */}
|
||||
{showNodeSelector && (
|
||||
<div className="header-node-selector" ref={nodeSelectorRef}>
|
||||
{/* Node status indicator - always visible */}
|
||||
<NodeStatusIndicator node={currentNode ?? null} showDetails />
|
||||
|
||||
{/* Node selector dropdown */}
|
||||
<button
|
||||
className={`btn-icon node-selector-trigger${isNodeSelectorOpen ? " node-selector-trigger--open" : ""}`}
|
||||
onClick={() => setIsNodeSelectorOpen((prev) => !prev)}
|
||||
title="Switch node"
|
||||
aria-label="Switch node"
|
||||
aria-expanded={isNodeSelectorOpen}
|
||||
aria-haspopup="listbox"
|
||||
data-testid="node-selector-trigger"
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={`node-selector-chevron${isNodeSelectorOpen ? " node-selector-chevron--open" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Node selector dropdown menu */}
|
||||
{isNodeSelectorOpen && (
|
||||
<div className="node-selector-dropdown" role="listbox" aria-label="Select node">
|
||||
{/* Local option */}
|
||||
<button
|
||||
className={`node-selector-option${!isRemote ? " node-selector-option--active" : ""}`}
|
||||
onClick={() => {
|
||||
onSelectNode?.(null);
|
||||
setIsNodeSelectorOpen(false);
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={!isRemote}
|
||||
data-testid="node-option-local"
|
||||
>
|
||||
<span className="node-selector-option-dot node-selector-option-dot--local" />
|
||||
<span className="node-selector-option-label">Local</span>
|
||||
</button>
|
||||
|
||||
{/* Remote nodes */}
|
||||
{remoteNodes.map((node) => (
|
||||
<button
|
||||
key={node.id}
|
||||
className={`node-selector-option${currentNode?.id === node.id ? " node-selector-option--active" : ""}`}
|
||||
onClick={() => {
|
||||
onSelectNode?.(node);
|
||||
setIsNodeSelectorOpen(false);
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={currentNode?.id === node.id}
|
||||
data-testid={`node-option-${node.id}`}
|
||||
>
|
||||
<span className={`node-selector-option-dot node-selector-option-dot--${node.status}`} />
|
||||
<span className="node-selector-option-label">{node.name}</span>
|
||||
<span className="node-selector-option-status">{node.status}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="header-actions">
|
||||
|
||||
@@ -68,6 +68,40 @@ vi.mock("../../hooks/useTasks", () => ({
|
||||
useTasks: () => mockUseTasks(),
|
||||
}));
|
||||
|
||||
// Mock useRemoteNodeData
|
||||
vi.mock("../../hooks/useRemoteNodeData", () => ({
|
||||
useRemoteNodeData: vi.fn(() => ({
|
||||
projects: [],
|
||||
tasks: [],
|
||||
health: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock useRemoteNodeEvents
|
||||
vi.mock("../../hooks/useRemoteNodeEvents", () => ({
|
||||
useRemoteNodeEvents: vi.fn(() => ({
|
||||
isConnected: false,
|
||||
lastEvent: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock NodeContext - default to local mode
|
||||
const mockNodeContextValue = {
|
||||
currentNode: null,
|
||||
currentNodeId: null,
|
||||
isRemote: false,
|
||||
setCurrentNode: vi.fn(),
|
||||
clearCurrentNode: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("../../context/NodeContext", () => ({
|
||||
NodeProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
useNodeContext: vi.fn(() => mockNodeContextValue),
|
||||
}));
|
||||
|
||||
// Mock state holders for dynamic mocking
|
||||
const mockProjectsState = {
|
||||
projects: [] as any[],
|
||||
@@ -116,6 +150,20 @@ vi.mock("../../hooks/useTerminal", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useNodes for node selector
|
||||
vi.mock("../../hooks/useNodes", () => ({
|
||||
useNodes: vi.fn(() => ({
|
||||
nodes: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
register: vi.fn(),
|
||||
update: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
healthCheck: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { App } from "../../App";
|
||||
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, updateSettings, runScript, fetchScripts } from "../../api";
|
||||
|
||||
@@ -141,6 +189,14 @@ beforeEach(() => {
|
||||
mockCurrentProjectState.currentProject = { id: DEFAULT_PROJECT_ID, name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" };
|
||||
mockCurrentProjectState.setCurrentProject.mockClear();
|
||||
mockCurrentProjectState.clearCurrentProject.mockClear();
|
||||
// Reset node context mocks
|
||||
mockNodeContextValue.currentNode = null;
|
||||
mockNodeContextValue.currentNodeId = null;
|
||||
mockNodeContextValue.isRemote = false;
|
||||
mockNodeContextValue.setCurrentNode.mockClear();
|
||||
mockNodeContextValue.clearCurrentNode.mockClear();
|
||||
// Clear node selection from localStorage to avoid cross-test leakage
|
||||
localStorage.removeItem("fusion-dashboard-current-node");
|
||||
});
|
||||
|
||||
describe("App deep link handling", () => {
|
||||
@@ -1426,3 +1482,256 @@ describe("App footer-safe project layout", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("App node mode switching", () => {
|
||||
it("does not render node selector when no remote nodes are available", async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for initial load to complete
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
});
|
||||
|
||||
// Node selector should not be visible when no remote nodes available
|
||||
expect(screen.queryByTestId("node-selector-trigger")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders node selector trigger when remote nodes are available", async () => {
|
||||
// Get the mocked useNodes and set up the return value
|
||||
const { useNodes } = await import("../../hooks/useNodes");
|
||||
vi.mocked(useNodes).mockReturnValue({
|
||||
nodes: [
|
||||
{
|
||||
id: "node_remote_1",
|
||||
name: "Remote Node 1",
|
||||
type: "remote" as const,
|
||||
url: "http://remote:4040",
|
||||
status: "online" as const,
|
||||
maxConcurrent: 2,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
register: vi.fn(),
|
||||
update: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
healthCheck: vi.fn(),
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for initial load to complete
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
});
|
||||
|
||||
// Node selector trigger should be visible when remote nodes available
|
||||
expect(screen.getByTestId("node-selector-trigger")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows remote node name when remote node is selected", async () => {
|
||||
// Get the mocked useNodes and set up the return value
|
||||
const { useNodes } = await import("../../hooks/useNodes");
|
||||
vi.mocked(useNodes).mockReturnValue({
|
||||
nodes: [
|
||||
{
|
||||
id: "node_remote_1",
|
||||
name: "Remote Node 1",
|
||||
type: "remote" as const,
|
||||
url: "http://remote:4040",
|
||||
status: "online" as const,
|
||||
maxConcurrent: 2,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
register: vi.fn(),
|
||||
update: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
healthCheck: vi.fn(),
|
||||
});
|
||||
|
||||
// Mock node context to return remote node
|
||||
mockNodeContextValue.currentNode = {
|
||||
id: "node_remote_1",
|
||||
name: "Remote Node 1",
|
||||
type: "remote",
|
||||
url: "http://remote:4040",
|
||||
status: "online",
|
||||
maxConcurrent: 2,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
mockNodeContextValue.currentNodeId = "node_remote_1";
|
||||
mockNodeContextValue.isRemote = true;
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for initial load to complete
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
});
|
||||
|
||||
// Should show remote node name
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Remote Node 1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls clearCurrentNode when Local option is selected", async () => {
|
||||
// Get the mocked useNodes and set up the return value
|
||||
const { useNodes } = await import("../../hooks/useNodes");
|
||||
vi.mocked(useNodes).mockReturnValue({
|
||||
nodes: [
|
||||
{
|
||||
id: "node_remote_1",
|
||||
name: "Remote Node 1",
|
||||
type: "remote" as const,
|
||||
url: "http://remote:4040",
|
||||
status: "online" as const,
|
||||
maxConcurrent: 2,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
register: vi.fn(),
|
||||
update: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
healthCheck: vi.fn(),
|
||||
});
|
||||
|
||||
// Mock node context to return remote node
|
||||
mockNodeContextValue.currentNode = {
|
||||
id: "node_remote_1",
|
||||
name: "Remote Node 1",
|
||||
type: "remote",
|
||||
url: "http://remote:4040",
|
||||
status: "online",
|
||||
maxConcurrent: 2,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
mockNodeContextValue.currentNodeId = "node_remote_1";
|
||||
mockNodeContextValue.isRemote = true;
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for initial load to complete
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Remote Node 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open the node selector
|
||||
fireEvent.click(screen.getByTestId("node-selector-trigger"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("node-option-local")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click Local option
|
||||
fireEvent.click(screen.getByTestId("node-option-local"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNodeContextValue.clearCurrentNode).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls setCurrentNode when a remote node is selected", async () => {
|
||||
// Get the mocked useNodes and set up the return value
|
||||
const { useNodes } = await import("../../hooks/useNodes");
|
||||
vi.mocked(useNodes).mockReturnValue({
|
||||
nodes: [
|
||||
{
|
||||
id: "node_remote_1",
|
||||
name: "Remote Node 1",
|
||||
type: "remote" as const,
|
||||
url: "http://remote:4040",
|
||||
status: "online" as const,
|
||||
maxConcurrent: 2,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "node_remote_2",
|
||||
name: "Remote Node 2",
|
||||
type: "remote" as const,
|
||||
url: "http://remote2:4040",
|
||||
status: "offline" as const,
|
||||
maxConcurrent: 2,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
register: vi.fn(),
|
||||
update: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
healthCheck: vi.fn(),
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for initial load to complete
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
});
|
||||
|
||||
// Open the node selector
|
||||
fireEvent.click(screen.getByTestId("node-selector-trigger"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("node-option-node_remote_2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the second remote node
|
||||
fireEvent.click(screen.getByTestId("node-option-node_remote_2"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNodeContextValue.setCurrentNode).toHaveBeenCalledWith({
|
||||
id: "node_remote_2",
|
||||
name: "Remote Node 2",
|
||||
type: "remote",
|
||||
url: "http://remote2:4040",
|
||||
status: "offline",
|
||||
maxConcurrent: 2,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user