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:
@@ -5,7 +5,6 @@
|
||||
- `TaskExecutor` terminates active agent sessions (single and step) when tasks are moved away from `in-progress` via the `task:moved` event handler. This prevents zombie sessions when users manually send tasks back to todo/triage from the board UI.
|
||||
- **Review Handoff (FN-1259)**: Agents can hand off tasks to users for human review via steering comments containing handoff phrases ("send it back to me", "hand off to user", etc.). When `reviewHandoffPolicy` is `"comment-triggered"`, the executor detects handoff intent in agent-authored steering comments and executes the handoff: sets `status: "awaiting-user-review"`, `assigneeUserId: "requesting-user"`, moves task to `in-review`, and disposes the agent session. The merger skips tasks with `"awaiting-user-review"` status (via `BLOCKING_TASK_STATUSES` in `task-merge.ts`). Users can accept review (clear status) or return to agent (move to todo). The `assigneeUserId` field stores the user ID who should review the task.
|
||||
- Agent preset templates in `NewAgentDialog.tsx` are a UI-only concept (`AgentPreset` interface), separate from the engine's `AgentPromptTemplate` type. Presets populate agent creation fields (name, icon, role, soul, instructionsText) but don't map to engine types.
|
||||
- Agent preset templates in `NewAgentDialog.tsx` are a UI-only concept (`AgentPreset` interface), separate from the engine's `AgentPromptTemplate` type. Presets populate agent creation fields (name, icon, role, soul, instructionsText) but don't map to engine types.
|
||||
- `soul` and `instructionsText` are already supported in `AgentCreateInput` and `AgentUpdateInput` — no API changes needed when adding these to presets.
|
||||
- `CronRunner` uses dependency injection for AI prompt execution: an `AiPromptExecutor` function is injected via options. This keeps it decoupled from `createKbAgent` and testable without real agent sessions.
|
||||
- `createAiPromptExecutor(cwd)` is an async factory function that creates a new agent session per call, uses `onText` for text accumulation, and disposes sessions in a `finally` block.
|
||||
@@ -287,3 +286,15 @@ The `@fusion/tui` package provides Ink-based React components for terminal UI.
|
||||
|
||||
- Kimi usage endpoint uses `/v1/coding_plan/usage` (underscore) as the primary endpoint — this is the Codexbar-validated working endpoint. The hyphen variant (`/v1/coding-plan/usage`) is a legacy fallback that may return 404 `url.not_found` for some accounts.
|
||||
- When implementing endpoint fallbacks for API providers, test the fallback logic with extra fields in the error payload (e.g., `{"code":5,"error":"url.not_found","message":"没找到对象",...}`) to ensure the fallback trigger remains robust.
|
||||
## FN-1516: Periodic Auto-Merge Sweep
|
||||
|
||||
- The `canAutoMergeTask()` function must be defined locally inside `runDashboard()` to work correctly with Vitest mocks. Module-level exports capture the real `getTaskMergeBlocker` at import time, before mocks are applied.
|
||||
- When importing shared utilities from dashboard.ts in serve.ts, ensure both files define compatible predicates (same `mergeRetries` limit check).
|
||||
- Periodic sweep tests using `vi.useFakeTimers()` must be isolated in their own test file or properly reset timers to avoid affecting subsequent tests.
|
||||
|
||||
## FN-1408: Node Provider and Remote Node Status
|
||||
|
||||
- When adding node context (`NodeProvider`, `useNodeContext`) to the App shell, update mocks in `App.test.tsx` for `useNodes`, `useRemoteNodeData`, `useRemoteNodeEvents`, and the `NodeContext` module.
|
||||
- Clear `fusion-dashboard-current-node` from localStorage in test `beforeEach` to avoid cross-test leakage.
|
||||
- Use `mockReturnValue` (not `mockReturnValueOnce`) for repeated mocks in tests with dynamic imports.
|
||||
- Add `await act(async () => { await new Promise((resolve) => setTimeout(resolve, 300)); })` in tests that wait for App initial load to complete before interacting with Header components.
|
||||
|
||||
@@ -165,3 +165,46 @@ Manage mission hierarchy and progression state.
|
||||
Inspect logs, step progress, workflow outcomes, and model overrides.
|
||||
|
||||

|
||||
|
||||
## Node Dashboard
|
||||
|
||||
The Node Dashboard provides a mesh view of connected Fusion nodes. Each node can be a local instance or a remote headless node (`fn serve`).
|
||||
|
||||
### Local/Remote Node Switching
|
||||
|
||||
When remote nodes are available, the dashboard header displays a node status indicator:
|
||||
|
||||
- **Local mode** — Shows a green "Local" badge, indicating the dashboard is connected to the local Fusion instance
|
||||
- **Remote mode** — Shows the remote node name with its connection status (online/offline/connecting)
|
||||
|
||||
Click the chevron next to the status indicator to open the node selector dropdown:
|
||||
|
||||
- **Local** — Switch back to viewing the local Fusion instance
|
||||
- **Remote nodes** — Select a remote node to view its tasks, projects, and status
|
||||
|
||||
### How Node Switching Works
|
||||
|
||||
1. The node selector appears in the header when remote nodes are registered in the mesh
|
||||
2. Selecting a remote node routes all API calls through the proxy endpoint (`/api/proxy/:nodeId/...`)
|
||||
3. Task data (projects, tasks) is fetched from the remote node and displayed in the dashboard
|
||||
4. SSE events from the remote node are streamed via the proxy and update the dashboard in real-time
|
||||
5. Selecting "Local" returns to the local Fusion instance with full local data
|
||||
|
||||
### Benefits of Remote Node Viewing
|
||||
|
||||
- Monitor task progress across distributed teams
|
||||
- View task status on remote headless nodes without direct SSH access
|
||||
- Compare project health across multiple Fusion instances
|
||||
- Stay informed about remote agent activity and task completion
|
||||
|
||||
### Node Status Indicators
|
||||
|
||||
| Status | Color | Meaning |
|
||||
|--------|-------|---------|
|
||||
| Online | Green | Node is connected and responsive |
|
||||
| Offline | Red | Node is unreachable or shut down |
|
||||
| Connecting | Yellow (pulsing) | Connection attempt in progress |
|
||||
|
||||
### Persistence
|
||||
|
||||
The selected node persists across browser sessions via localStorage. If the selected remote node is unregistered, the dashboard automatically falls back to local mode.
|
||||
|
||||
@@ -30,6 +30,9 @@ import { useAuthOnboarding } from "./hooks/useAuthOnboarding";
|
||||
import { useViewState, type TaskView } from "./hooks/useViewState";
|
||||
import { useProjectActions } from "./hooks/useProjectActions";
|
||||
import { useTaskHandlers } from "./hooks/useTaskHandlers";
|
||||
import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
|
||||
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
|
||||
import { NodeProvider, useNodeContext } from "./context/NodeContext";
|
||||
import type { AiSessionSummary } from "./api";
|
||||
|
||||
function AppInner() {
|
||||
@@ -41,6 +44,28 @@ function AppInner() {
|
||||
const { nodes } = useNodes();
|
||||
const { currentProject, setCurrentProject, clearCurrentProject, loading: currentProjectLoading } = useCurrentProject(projects);
|
||||
|
||||
// Node context for local/remote node switching
|
||||
const { currentNode, currentNodeId, isRemote, setCurrentNode, clearCurrentNode } = useNodeContext();
|
||||
|
||||
// Sync node context with useNodes() results - fall back to local if selected node is missing
|
||||
useEffect(() => {
|
||||
if (currentNodeId && nodes.length > 0) {
|
||||
const nodeExists = nodes.some((n) => n.id === currentNodeId);
|
||||
if (!nodeExists) {
|
||||
// Selected node was deleted or unregistered - fall back to local
|
||||
clearCurrentNode();
|
||||
}
|
||||
}
|
||||
}, [currentNodeId, nodes, clearCurrentNode]);
|
||||
|
||||
// Remote node data and events when in remote mode
|
||||
const remoteData = useRemoteNodeData(currentNodeId, { projectId: currentProject?.id });
|
||||
const remoteEvents = useRemoteNodeEvents(currentNodeId);
|
||||
|
||||
// Use remote data when in remote mode, local data otherwise
|
||||
const effectiveProjects = isRemote && remoteData.projects.length > 0 ? remoteData.projects : projects;
|
||||
const effectiveTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : [];
|
||||
|
||||
// Search query state - must be defined before useTasks
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
@@ -309,7 +334,7 @@ function AppInner() {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<Board
|
||||
tasks={tasks}
|
||||
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
|
||||
projectId={currentProject?.id}
|
||||
maxConcurrent={maxConcurrent}
|
||||
onMoveTask={moveTask}
|
||||
@@ -344,7 +369,7 @@ function AppInner() {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<ListView
|
||||
tasks={tasks}
|
||||
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
|
||||
projectId={currentProject?.id}
|
||||
onMoveTask={moveTask}
|
||||
onOpenDetail={modalManager.openDetailTask}
|
||||
@@ -405,12 +430,23 @@ function AppInner() {
|
||||
onChangeView={viewMode === "project" && currentProject ? handleTaskViewChange : undefined}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
projects={projects}
|
||||
projects={effectiveProjects}
|
||||
currentProject={currentProject}
|
||||
onSelectProject={handleSelectProject}
|
||||
onViewAllProjects={handleViewAllProjects}
|
||||
projectId={currentProject?.id}
|
||||
mobileNavEnabled={isMobile}
|
||||
// Node switching props
|
||||
availableNodes={nodes}
|
||||
currentNode={currentNode}
|
||||
onSelectNode={(node) => {
|
||||
if (node === null) {
|
||||
clearCurrentNode();
|
||||
} else {
|
||||
setCurrentNode(node);
|
||||
}
|
||||
}}
|
||||
isRemote={isRemote}
|
||||
/>
|
||||
{viewMode === "project" && currentProject && !nodesOpen && (
|
||||
<SessionNotificationBanner
|
||||
@@ -427,7 +463,7 @@ function AppInner() {
|
||||
</div>
|
||||
{viewMode === "project" && currentProject && !nodesOpen && (
|
||||
<ExecutorStatusBar
|
||||
tasks={tasks}
|
||||
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
|
||||
projectId={currentProject.id}
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
backgroundSessions={bgSessions}
|
||||
@@ -492,7 +528,9 @@ function AppInner() {
|
||||
export function App() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<AppInner />
|
||||
<NodeProvider>
|
||||
<AppInner />
|
||||
</NodeProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -610,6 +610,209 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Node Selector */
|
||||
.header-node-selector {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
margin-right: var(--space-sm);
|
||||
}
|
||||
|
||||
.node-selector-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.node-selector-trigger:hover {
|
||||
color: var(--text);
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.node-selector-trigger--open {
|
||||
color: var(--todo);
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.node-selector-chevron {
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.node-selector-chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.node-selector-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-xs));
|
||||
right: 0;
|
||||
min-width: 180px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: var(--space-xs);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.node-selector-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.node-selector-option:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.node-selector-option--active {
|
||||
background: var(--card);
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
.node-selector-option-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-selector-option-dot--local {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.node-selector-option-dot--online {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.node-selector-option-dot--offline {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.node-selector-option-dot--connecting {
|
||||
background: var(--triage);
|
||||
animation: node-connecting-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.node-selector-option-dot--error {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
@keyframes node-connecting-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.node-selector-option-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.node-selector-option-status {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* Node Status Indicator */
|
||||
.node-status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: 2px var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.node-status-indicator--local {
|
||||
background: var(--state-active-bg);
|
||||
color: var(--state-active-text);
|
||||
}
|
||||
|
||||
.node-status-indicator--remote {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.node-status-indicator__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-status-indicator__dot--online {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.node-status-indicator__dot--offline {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.node-status-indicator__dot--connecting {
|
||||
background: var(--triage);
|
||||
animation: node-connecting-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.node-status-indicator__dot--error {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.node-status-indicator__spinner {
|
||||
display: block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border: 1px solid var(--triage);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.node-status-indicator__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.node-status-indicator__name {
|
||||
font-weight: 500;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.node-status-indicator__details {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* View Toggle */
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user