feat(FN-2845): streamline node routing configuration in dashboard

- Add a reusable ProjectNodeSelector component and hook RoutingTab to it for per-task node overrides
- Move default node and unavailable-node policy controls into the dedicated Node Routing settings section
- Add descriptive routing guidance text and project-level note in SettingsModal
- Expand dashboard tests for routing tab behavior and node routing settings UX
This commit is contained in:
Fusion
2026-04-28 11:01:30 -07:00
committed by gsxdsm
parent 9a1c21f373
commit 3d44398920
9 changed files with 494 additions and 432 deletions

View File

@@ -0,0 +1,25 @@
.project-node-selector {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.project-node-selector__label {
color: var(--text-muted);
font-size: 0.75rem;
}
.project-node-selector .select {
width: 100%;
max-width: 20rem;
}
.project-node-selector__option--dim {
color: var(--text-muted);
}
@media (max-width: 768px) {
.project-node-selector .select {
max-width: 100%;
}
}

View File

@@ -1,5 +1,6 @@
import { useMemo } from "react";
import type { NodeInfo } from "../api";
import "./ProjectNodeSelector.css";
interface ProjectNodeSelectorProps {
projectId: string;
@@ -33,6 +34,7 @@ export function ProjectNodeSelector({
<label className="project-node-selector" htmlFor={`project-node-selector-${projectId}`}>
<span className="project-node-selector__label">Runtime Node</span>
<select
className="select"
id={`project-node-selector-${projectId}`}
value={selectedValue}
onChange={(event) => {

View File

@@ -1,117 +1,34 @@
.routing-tab {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.routing-tab h4 {
margin: 0;
}
.routing-tab h5 {
margin: 0;
color: var(--text);
}
.routing-tab__intro {
margin: 0;
color: var(--text-muted);
}
.routing-tab__section {
display: flex;
flex-direction: column;
gap: var(--space-md);
padding: var(--space-lg);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
}
.routing-summary-grid {
display: grid;
gap: var(--space-sm);
}
.routing-summary-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: var(--space-md);
padding: var(--space-sm) 0;
border-bottom: 1px solid var(--border);
}
.routing-summary-row:last-child {
border-bottom: 0;
}
.routing-summary-label {
color: var(--text-muted);
}
.routing-summary-value {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
color: var(--text);
}
.routing-summary-warning {
display: inline-flex;
align-items: center;
padding: 0 var(--space-sm);
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-warning) 16%, transparent);
color: var(--color-warning);
}
.routing-tab__info-banner,
.routing-tab__warning-banner,
.routing-tab__error {
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
}
.routing-tab__info-banner,
.routing-tab__warning-banner {
background: color-mix(in srgb, var(--color-warning) 14%, transparent);
color: var(--color-warning);
border: 1px solid color-mix(in srgb, var(--color-warning) 35%, transparent);
}
.routing-tab__error {
background: var(--status-error-bg);
color: var(--color-error);
border: 1px solid color-mix(in srgb, var(--color-error) 40%, transparent);
}
.routing-tab__selector-label {
color: var(--text-muted);
}
.routing-tab__selector {
width: 100%;
}
.routing-tab__override-row {
.routing-tab-summary {
margin-bottom: var(--space-lg);
}
.routing-tab-override {
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: column;
gap: var(--space-sm);
}
.routing-tab__override-text {
color: var(--text-muted);
.routing-tab-blocked {
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
color: var(--color-warning);
border: 1px solid color-mix(in srgb, var(--color-warning) 30%, transparent);
border-radius: var(--radius-md);
padding: var(--space-md) var(--space-lg);
font-size: 0.85rem;
margin-top: var(--space-sm);
}
.routing-tab-actions {
margin-top: var(--space-sm);
}
@media (max-width: 768px) {
.routing-summary-row {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-xs);
}
.routing-tab__override-row {
flex-direction: column;
align-items: flex-start;
.routing-tab-summary,
.routing-tab-override {
margin-bottom: var(--space-md);
}
}

View File

@@ -1,209 +1,136 @@
import "./RoutingTab.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Settings, Task, TaskDetail } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import { useCallback, useEffect, useMemo, useState } from "react";
import { getErrorMessage, type Settings, type Task, type TaskDetail } from "@fusion/core";
import { fetchNodes, updateTask } from "../api";
import type { NodeInfo } from "../api";
import type { ToastType } from "../hooks/useToast";
import { ProjectNodeSelector } from "./ProjectNodeSelector";
interface RoutingTabProps {
task: Task | TaskDetail;
settings?: Settings;
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
onTaskUpdated?: (task: Task) => void;
}
const STATUS_DOT: Record<NodeInfo["status"], string> = {
online: "🟢",
offline: "🔴",
connecting: "🟡",
error: "🔴",
};
type RoutingSettings = Settings & {
defaultNodeId?: string;
unavailableNodePolicy?: "block" | "fallback-local";
};
function getRoutingPolicyLabel(policy: RoutingSettings["unavailableNodePolicy"] | undefined): string {
function resolveUnavailablePolicy(policy?: string): string {
if (policy === "block") return "Block execution";
if (policy === "fallback-local") return "Fall back to local";
return "Not configured";
}
function isUnhealthy(status: NodeInfo["status"] | undefined): boolean {
return status !== undefined && status !== "online";
}
export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingTabProps) {
export function RoutingTab({ task, settings, projectId, addToast, onTaskUpdated }: RoutingTabProps) {
const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [loadingNodes, setLoadingNodes] = useState(false);
const [nodesError, setNodesError] = useState<string | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string>(task.nodeId ?? "");
const [savingNode, setSavingNode] = useState(false);
const activeTaskIdRef = useRef(task.id);
const isInProgress = task.column === "in-progress";
useEffect(() => {
activeTaskIdRef.current = task.id;
setSelectedNodeId(task.nodeId ?? "");
setSavingNode(false);
}, [task.id, task.nodeId]);
useEffect(() => {
setLoadingNodes(true);
setNodesError(null);
let mounted = true;
fetchNodes()
.then((result) => {
setNodes(result);
.then((fetchedNodes) => {
if (mounted) setNodes(fetchedNodes);
})
.catch((err) => {
setNodesError(getErrorMessage(err) || "Failed to load nodes");
})
.finally(() => {
setLoadingNodes(false);
.catch((error) => {
if (mounted) {
addToast(`Failed to load nodes: ${getErrorMessage(error)}`, "error");
}
});
}, []);
const nodesById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
const sortedNodes = useMemo(
() => [...nodes].sort((a, b) => a.name.localeCompare(b.name)),
[nodes],
);
return () => {
mounted = false;
};
}, [addToast]);
const effectiveNodeName = useMemo(() => {
if (task.nodeId) {
const taskNode = nodes.find((node) => node.id === task.nodeId);
return taskNode ? taskNode.name : `${task.nodeId} (unknown node)`;
}
if (settings?.defaultNodeId) {
const defaultNode = nodes.find((node) => node.id === settings.defaultNodeId);
return defaultNode ? `${defaultNode.name} (project default)` : `${settings.defaultNodeId} (unknown node)`;
}
return "Local (no routing configured)";
}, [nodes, settings?.defaultNodeId, task.nodeId]);
const routingSettings = settings as RoutingSettings | undefined;
const effectiveNodeId = task.nodeId ?? routingSettings?.defaultNodeId ?? null;
const routingSource = task.nodeId
? "Per-task override"
: routingSettings?.defaultNodeId
: settings?.defaultNodeId
? "Project default"
: "No routing";
const effectiveNode = effectiveNodeId ? nodesById.get(effectiveNodeId) : undefined;
const effectiveNodeName = effectiveNode
? `${STATUS_DOT[effectiveNode.status]} ${effectiveNode.name} (${effectiveNode.type})`
: effectiveNodeId
? `${effectiveNodeId} (node unavailable or unknown)`
: "Local (no routing configured)";
const taskInProgress = task.column === "in-progress";
const selectorDisabled = taskInProgress || savingNode || loadingNodes;
const blockingReason = (task as Task & { blockedReason?: string; statusReason?: string }).blockedReason
|| (task as Task & { statusReason?: string }).statusReason;
const handleNodeSelect = useCallback(
async (nextValue: string) => {
if (nextValue === selectedNodeId) {
return;
}
const requestTaskId = task.id;
const previousValue = selectedNodeId;
setSelectedNodeId(nextValue);
setSavingNode(true);
async (selectedNodeId: string | null) => {
try {
const updatedTask = await updateTask(requestTaskId, { nodeId: nextValue || null });
if (activeTaskIdRef.current !== requestTaskId) return;
setSelectedNodeId(updatedTask.nodeId ?? "");
onTaskUpdated?.(updatedTask);
addToast(nextValue ? "Node override updated" : "Node override cleared", "success");
} catch (err) {
if (activeTaskIdRef.current !== requestTaskId) return;
setSelectedNodeId(previousValue);
addToast(getErrorMessage(err) || "Failed to update node override", "error");
} finally {
if (activeTaskIdRef.current === requestTaskId) {
setSavingNode(false);
}
const updated = await updateTask(task.id, { nodeId: selectedNodeId || null });
addToast("Node override updated", "success");
onTaskUpdated?.(updated);
} catch (error) {
addToast(`Failed to update node override: ${getErrorMessage(error)}`, "error");
}
},
[addToast, onTaskUpdated, selectedNodeId, task.id],
[addToast, onTaskUpdated, task.id],
);
const clearOverride = useCallback(() => {
void handleNodeSelect("");
const handleClearOverride = useCallback(async () => {
await handleNodeSelect(null);
}, [handleNodeSelect]);
return (
<div className="routing-tab">
<h4>Task Routing</h4>
<p className="routing-tab__intro">View the effective execution node and control per-task node override.</p>
<div className="routing-tab-summary">
<h4>Node Routing Summary</h4>
<dl className="detail-source-grid">
<div>
<dt>Effective Node</dt>
<dd>{effectiveNodeName}</dd>
</div>
<div>
<dt>Routing Source</dt>
<dd>{routingSource}</dd>
</div>
<div>
<dt>Unavailable Node Policy</dt>
<dd>{resolveUnavailablePolicy(settings?.unavailableNodePolicy)}</dd>
</div>
<div>
<dt>Blocking Reason</dt>
<dd>{blockingReason ?? <span className="detail-source-empty">(not blocked)</span>}</dd>
</div>
</dl>
</div>
<section className="routing-tab__section">
<h5>Routing Summary</h5>
<div className="routing-summary-grid" role="list">
<div className="routing-summary-row" role="listitem">
<span className="routing-summary-label">Effective node</span>
<span className="routing-summary-value">
{effectiveNodeName}
{isUnhealthy(effectiveNode?.status) ? (
<span className="routing-summary-warning">Unhealthy</span>
) : null}
</span>
</div>
<div className="routing-summary-row" role="listitem">
<span className="routing-summary-label">Routing source</span>
<span className="routing-summary-value">{routingSource}</span>
</div>
<div className="routing-summary-row" role="listitem">
<span className="routing-summary-label">Unavailable-node policy</span>
<span className="routing-summary-value">{getRoutingPolicyLabel(routingSettings?.unavailableNodePolicy)}</span>
</div>
</div>
{taskInProgress && effectiveNodeId ? (
<div className="routing-tab__info-banner">
Routing is locked while this task is active. Node override cannot be changed until the task leaves in-progress.
</div>
) : null}
</section>
<section className="routing-tab__section">
<h5>Node Override</h5>
{taskInProgress ? (
<div className="routing-tab__warning-banner">
Node override cannot be changed while the task is in progress.
</div>
) : null}
<label className="routing-tab__selector-label" htmlFor={`routing-node-${task.id}`}>
Select execution node
</label>
<select
id={`routing-node-${task.id}`}
className="select routing-tab__selector"
value={selectedNodeId}
disabled={selectorDisabled}
onChange={(event) => {
void handleNodeSelect(event.target.value);
<div className="routing-tab-override">
<h4>Node Override</h4>
<ProjectNodeSelector
projectId={projectId ?? ""}
nodes={nodes}
currentNodeId={task.nodeId ?? undefined}
onSelect={(nodeId) => {
void handleNodeSelect(nodeId);
}}
>
<option value="">Use project default</option>
{sortedNodes.map((node) => (
<option key={node.id} value={node.id}>
{STATUS_DOT[node.status]} {node.name} ({node.type})
</option>
))}
</select>
{nodesError ? <div className="routing-tab__error">{nodesError}</div> : null}
{task.nodeId ? (
<div className="routing-tab__override-row">
<span className="routing-tab__override-text">
Override set to: {nodesById.get(task.nodeId)?.name ?? task.nodeId}
</span>
<button
type="button"
className="btn btn-sm"
disabled={taskInProgress || savingNode}
onClick={clearOverride}
>
disabled={isInProgress}
/>
{isInProgress ? (
<div className="routing-tab-blocked">
Node override cannot be changed while the task is in progress. Wait for the task to complete or move it
back to todo first.
</div>
) : null}
{task.nodeId && !isInProgress ? (
<div className="routing-tab-actions">
<button type="button" className="btn btn-sm" onClick={() => void handleClearOverride()}>
Clear override
</button>
</div>
) : null}
</section>
</div>
</div>
);
}

View File

@@ -1194,6 +1194,52 @@
grid-template-columns: 1fr;
gap: var(--space-xs);
}
.settings-node-routing-note {
padding: var(--space-sm) var(--space-md);
font-size: 12px;
}
}
.settings-node-status {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
margin-top: var(--space-sm);
font-size: 12px;
color: var(--text-muted);
}
.settings-node-status__dot {
width: var(--space-sm);
height: var(--space-sm);
border-radius: var(--radius-pill);
background: var(--color-muted);
}
.settings-node-status--online .settings-node-status__dot {
background: var(--color-success);
}
.settings-node-status--offline .settings-node-status__dot,
.settings-node-status--error .settings-node-status__dot {
background: var(--color-error);
}
.settings-node-status--connecting .settings-node-status__dot {
background: var(--color-warning);
}
/* Node Routing section */
.settings-node-routing-note {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-md) var(--space-lg);
margin-top: var(--space-lg);
color: var(--text-muted);
font-size: 13px;
line-height: 1.5;
}
.settings-node-status {

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef, lazy, Suspense, type MouseEve
import { Globe, Folder, RefreshCw, Star, HelpCircle, Loader2 } from "lucide-react";
import { THINKING_LEVELS, isGlobalSettingsKey, isProjectSettingsKey, getErrorMessage } from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, activateRemoteProvider, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
@@ -2415,55 +2415,6 @@ export function SettingsModal({
/>
<small>Maximum concurrent planning agents</small>
</div>
<div className="form-group">
<label htmlFor="defaultNodeId">Default Execution Node</label>
<select
id="defaultNodeId"
className="select"
value={typeof form.defaultNodeId === "string" ? form.defaultNodeId : ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, defaultNodeId: val || undefined } as SettingsFormState));
}}
>
<option value="">Local execution (no default node)</option>
{nodes.map((node) => (
<option key={node.id} value={node.id}>
{node.name} ({getNodeStatusLabel(node.status)})
</option>
))}
</select>
{(() => {
const selectedNode = nodes.find((node) => node.id === form.defaultNodeId);
if (!selectedNode) return null;
return (
<div className={`settings-node-status ${getNodeStatusClass(selectedNode.status)}`}>
<span className="settings-node-status__dot" aria-hidden="true" />
<span>{`Selected node: ${getNodeStatusLabel(selectedNode.status)}`}</span>
</div>
);
})()}
<small>Used when a task has no node override. Node status is shown for safer routing selection.</small>
</div>
<div className="form-group">
<label htmlFor="unavailableNodePolicy">Unavailable Node Policy</label>
<select
id="unavailableNodePolicy"
className="select"
value={
form.unavailableNodePolicy === "fallback-local" ? "fallback-local" : "block"
}
onChange={(e) =>
setForm((f) => ({
...f,
unavailableNodePolicy: e.target.value as "block" | "fallback-local",
} as SettingsFormState))
}
>
<option value="block">Block execution</option>
<option value="fallback-local">Fallback to local</option>
</select>
</div>
<div className="form-group">
<label htmlFor="pollIntervalMs">Poll Interval (ms)</label>
<input
@@ -2697,6 +2648,7 @@ export function SettingsModal({
<>
{renderScopeBanner()}
<h4 className="settings-section-heading">Node Routing</h4>
<p className="settings-description">Configure how tasks are routed to execution nodes. The default node determines where tasks run when no per-task override is set. The policy controls what happens when that node is unavailable.</p>
<div className="form-group">
<label htmlFor="defaultNodeId">Default Execution Node</label>
<select
@@ -2743,12 +2695,15 @@ export function SettingsModal({
}
>
<option value="block">Block execution</option>
<option value="fallback-local">Fallback to local</option>
<option value="fallback-local">Fall back to local</option>
</select>
<small>Controls what happens when the selected node is offline or unhealthy. "Block execution" keeps tasks waiting until the node recovers. "Fall back to local" runs tasks on the local node instead.</small>
</div>
<div className="settings-node-routing-note">
These settings apply at the project level. Individual tasks can override the default node from the task detail modal.
</div>
</>
);
case "worktrees":
return (
<>

View File

@@ -142,26 +142,6 @@
font-size: 13px;
}
.project-node-selector {
display: flex;
flex-direction: column;
gap: 6px;
}
.project-node-selector__label {
color: var(--text-muted);
font-size: 12px;
}
.project-node-selector select {
width: 100%;
max-width: 320px;
}
.project-node-selector__option--dim {
color: var(--text-muted);
}
.node-badge {
display: inline-flex;
align-items: center;

View File

@@ -1,9 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { Settings, Task } from "@fusion/core";
import { RoutingTab } from "../RoutingTab";
import * as api from "../../api";
import { RoutingTab } from "../RoutingTab";
vi.mock("lucide-react", () => ({}));
@@ -16,13 +15,22 @@ vi.mock("../../api", async () => {
};
});
vi.mock("../ProjectNodeSelector", () => ({
ProjectNodeSelector: (props: any) => (
<div data-testid="node-selector" data-disabled={String(Boolean(props.disabled))}>
<button type="button" onClick={() => props.onSelect("node-2")}>select-node-2</button>
<button type="button" onClick={() => props.onSelect(null)}>select-none</button>
</div>
),
}));
const mockFetchNodes = api.fetchNodes as ReturnType<typeof vi.fn>;
const mockUpdateTask = api.updateTask as ReturnType<typeof vi.fn>;
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-001",
description: "Routing test task",
id: "FN-2845",
description: "routing test",
column: "todo",
dependencies: [],
steps: [],
@@ -34,66 +42,43 @@ function makeTask(overrides: Partial<Task> = {}): Task {
};
}
type RoutingSettings = Settings & {
defaultNodeId?: string;
unavailableNodePolicy?: "block" | "fallback-local";
};
function makeSettings(overrides: Partial<RoutingSettings> = {}): RoutingSettings {
function makeSettings(overrides: Partial<Settings> = {}): Settings {
return {
maxConcurrent: 2,
maxWorktrees: 2,
pollIntervalMs: 10000,
maxConcurrent: 1,
maxWorktrees: 1,
pollIntervalMs: 1000,
autoMerge: false,
groupOverlappingFiles: false,
autoMerge: true,
...overrides,
};
}
describe("RoutingTab", () => {
const addToast = vi.fn();
const onTaskUpdated = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockFetchNodes.mockResolvedValue([
{ id: "node-a", name: "Alpha", type: "local", status: "online" },
{ id: "node-b", name: "Beta", type: "remote", status: "offline" },
{ id: "node-1", name: "Worker Alpha", type: "remote", status: "online", maxConcurrent: 5, createdAt: "", updatedAt: "" },
{ id: "node-2", name: "Worker Beta", type: "remote", status: "online", maxConcurrent: 5, createdAt: "", updatedAt: "" },
]);
mockUpdateTask.mockImplementation(async (_id: string, updates: { nodeId?: string | null }) => {
return makeTask({ nodeId: updates.nodeId ?? undefined });
});
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => makeTask(updates as Partial<Task>));
});
it("renders routing summary with per-task override", async () => {
render(
<RoutingTab
task={makeTask({ nodeId: "node-a" })}
settings={makeSettings({ defaultNodeId: "node-b" })}
addToast={addToast}
/>,
);
expect(await screen.findByText("Per-task override")).toBeInTheDocument();
expect(screen.getByText(/Effective node/i)).toBeInTheDocument();
render(<RoutingTab task={makeTask({ nodeId: "node-1" })} addToast={addToast} settings={makeSettings()} />);
await screen.findByText("Worker Alpha");
expect(screen.getByText("Per-task override")).toBeInTheDocument();
});
it("renders routing summary with project default", async () => {
render(
<RoutingTab
task={makeTask()}
settings={makeSettings({ defaultNodeId: "node-a" })}
addToast={addToast}
/>,
);
expect(await screen.findByText("Project default")).toBeInTheDocument();
expect(screen.getByText(/Effective node/i)).toBeInTheDocument();
render(<RoutingTab task={makeTask()} addToast={addToast} settings={makeSettings({ defaultNodeId: "node-2" })} />);
await screen.findByText("Worker Beta (project default)");
expect(screen.getByText("Project default")).toBeInTheDocument();
});
it("renders no-routing summary when no override or project default exists", async () => {
render(<RoutingTab task={makeTask()} settings={makeSettings()} addToast={addToast} />);
it("renders routing summary with no routing", async () => {
render(<RoutingTab task={makeTask()} addToast={addToast} settings={makeSettings()} />);
expect(await screen.findByText("Local (no routing configured)")).toBeInTheDocument();
expect(screen.getByText("No routing")).toBeInTheDocument();
});
@@ -101,69 +86,43 @@ describe("RoutingTab", () => {
it.each([
["block", "Block execution"],
["fallback-local", "Fall back to local"],
] as const)("displays unavailable-node policy: %s", async (policy, label) => {
render(
<RoutingTab
task={makeTask()}
settings={makeSettings({ unavailableNodePolicy: policy })}
addToast={addToast}
/>,
);
await screen.findByText(label);
expect(screen.getByText(label)).toBeInTheDocument();
])("displays unavailable-node policy %s", async (policy, text) => {
render(<RoutingTab task={makeTask()} addToast={addToast} settings={makeSettings({ unavailableNodePolicy: policy as any })} />);
expect(await screen.findByText(text)).toBeInTheDocument();
});
it("disables node selector for in-progress tasks", async () => {
render(<RoutingTab task={makeTask({ column: "in-progress" })} settings={makeSettings()} addToast={addToast} />);
const selector = await screen.findByLabelText("Select execution node");
expect(selector).toBeDisabled();
expect(screen.getByText("Node override cannot be changed while the task is in progress.")).toBeInTheDocument();
it("disables selector and shows warning for in-progress tasks", async () => {
render(<RoutingTab task={makeTask({ column: "in-progress" })} addToast={addToast} settings={makeSettings()} />);
await waitFor(() => expect(screen.getByTestId("node-selector")).toHaveAttribute("data-disabled", "true"));
expect(screen.getByText(/Node override cannot be changed while the task is in progress/i)).toBeInTheDocument();
});
it("enables node selector for non-in-progress tasks", async () => {
render(<RoutingTab task={makeTask({ column: "todo" })} settings={makeSettings()} addToast={addToast} />);
const selector = await screen.findByLabelText("Select execution node");
expect(selector).toBeEnabled();
it("enables selector for non-in-progress tasks", async () => {
render(<RoutingTab task={makeTask({ column: "todo" })} addToast={addToast} settings={makeSettings()} />);
await waitFor(() => expect(screen.getByTestId("node-selector")).toHaveAttribute("data-disabled", "false"));
expect(screen.queryByText(/Node override cannot be changed/i)).not.toBeInTheDocument();
});
it("calls updateTask when node selected", async () => {
const user = userEvent.setup();
render(
<RoutingTab
task={makeTask({ column: "todo" })}
settings={makeSettings()}
addToast={addToast}
onTaskUpdated={onTaskUpdated}
/>,
);
const selector = await screen.findByLabelText("Select execution node");
await user.selectOptions(selector, "node-a");
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", { nodeId: "node-a" });
});
render(<RoutingTab task={makeTask()} addToast={addToast} settings={makeSettings()} />);
fireEvent.click(await screen.findByText("select-node-2"));
await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledWith("FN-2845", { nodeId: "node-2" }));
});
it("shows clear override button and clears node override", async () => {
const user = userEvent.setup();
render(
<RoutingTab
task={makeTask({ nodeId: "node-a" })}
settings={makeSettings()}
addToast={addToast}
onTaskUpdated={onTaskUpdated}
/>,
);
it("shows clear override button and clears override", async () => {
render(<RoutingTab task={makeTask({ nodeId: "node-1", column: "todo" })} addToast={addToast} settings={makeSettings()} />);
const button = await screen.findByRole("button", { name: "Clear override" });
fireEvent.click(button);
await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledWith("FN-2845", { nodeId: null }));
});
const clearButton = await screen.findByRole("button", { name: "Clear override" });
await user.click(clearButton);
it("hides clear override button for in-progress tasks", () => {
render(<RoutingTab task={makeTask({ nodeId: "node-1", column: "in-progress" })} addToast={addToast} settings={makeSettings()} />);
expect(screen.queryByRole("button", { name: "Clear override" })).not.toBeInTheDocument();
});
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", { nodeId: null });
});
it("shows unknown node IDs as raw id", async () => {
render(<RoutingTab task={makeTask({ nodeId: "ghost-node" })} addToast={addToast} settings={makeSettings()} />);
expect(await screen.findByText("ghost-node (unknown node)")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,251 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SettingsModal } from "../SettingsModal";
const mockFetchSettings = vi.fn();
const mockFetchSettingsByScope = vi.fn();
const mockUpdateSettings = vi.fn();
const mockFetchAuthStatus = vi.fn();
const mockFetchModels = vi.fn();
const mockFetchBackups = vi.fn();
const mockFetchMemoryFiles = vi.fn();
const mockFetchMemoryFile = vi.fn();
const mockFetchGlobalConcurrency = vi.fn();
const mockFetchMemoryBackendStatus = vi.fn();
const mockFetchGitRemotesDetailed = vi.fn();
const mockFetchDashboardHealth = vi.fn();
const mockCheckForUpdates = vi.fn();
const mockFetchRemoteSettings = vi.fn();
const mockUseMemoryBackendStatus = vi.fn();
const mockNodes = [
{ id: "node-local", name: "Local Machine", type: "local", status: "online", maxConcurrent: 4, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
{ id: "node-remote-1", name: "Alpha Worker", type: "remote", status: "online", maxConcurrent: 8, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
{ id: "node-remote-2", name: "Beta Node", type: "remote", status: "offline", maxConcurrent: 4, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
];
vi.mock("../../api", () => ({
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
fetchSettingsByScope: (...args: unknown[]) => mockFetchSettingsByScope(...args),
updateSettings: (...args: unknown[]) => mockUpdateSettings(...args),
updateGlobalSettings: vi.fn(),
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
fetchBackups: (...args: unknown[]) => mockFetchBackups(...args),
fetchMemoryFiles: (...args: unknown[]) => mockFetchMemoryFiles(...args),
fetchMemoryFile: (...args: unknown[]) => mockFetchMemoryFile(...args),
saveMemoryFile: vi.fn(),
compactMemory: vi.fn(),
fetchGlobalConcurrency: (...args: unknown[]) => mockFetchGlobalConcurrency(...args),
updateGlobalConcurrency: vi.fn(),
fetchMemoryBackendStatus: (...args: unknown[]) => mockFetchMemoryBackendStatus(...args),
testMemoryRetrieval: vi.fn(),
installQmd: vi.fn(),
fetchGitRemotesDetailed: (...args: unknown[]) => mockFetchGitRemotesDetailed(...args),
fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args),
checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args),
fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args),
updateRemoteSettings: vi.fn(),
fetchRemoteStatus: vi.fn(),
activateRemoteProvider: vi.fn(),
startRemoteTunnel: vi.fn(),
stopRemoteTunnel: vi.fn(),
regenerateRemotePersistentToken: vi.fn(),
generateShortLivedRemoteToken: vi.fn(),
fetchRemoteQr: vi.fn(),
fetchRemoteUrl: vi.fn(),
triggerMemoryDreams: vi.fn(),
exportSettings: vi.fn(),
importSettings: vi.fn(),
createBackup: vi.fn(),
testNtfyNotification: vi.fn(),
loginProvider: vi.fn(),
logoutProvider: vi.fn(),
}));
vi.mock("../../hooks/useNodes", () => ({
useNodes: vi.fn(() => ({
nodes: mockNodes,
loading: false,
error: null,
refresh: vi.fn(),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
healthCheck: vi.fn(),
})),
}));
vi.mock("../../hooks/useMemoryBackendStatus", () => ({
useMemoryBackendStatus: (...args: unknown[]) => mockUseMemoryBackendStatus(...args),
}));
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>();
return { ...actual, Globe: () => <span />, Folder: () => <span />, RefreshCw: () => <span />, Star: () => <span />, HelpCircle: () => <span />, Loader2: () => <span /> };
});
vi.mock("../PluginManager", () => ({ PluginManager: () => <div /> }));
vi.mock("../PiExtensionsManager", () => ({ PiExtensionsManager: () => <div /> }));
vi.mock("../PluginSlot", () => ({ PluginSlot: () => <div /> }));
vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({ useWorkspaceFileBrowser: () => ({ isOpen: false, open: vi.fn(), close: vi.fn(), entries: [], loading: false, error: null, selectedPath: null, navigateTo: vi.fn(), selectPath: vi.fn() }) }));
vi.mock("../FileBrowser", () => ({ FileBrowser: () => <div /> }));
const baseSettings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: true,
overlapIgnorePaths: [],
autoMerge: true,
mergeStrategy: "direct",
pushAfterMerge: false,
pushRemote: "origin",
recycleWorktrees: false,
worktreeNaming: "random",
includeTaskIdInCommit: true,
};
function renderModal() {
return render(<SettingsModal onClose={() => {}} addToast={() => {}} />);
}
async function ready() {
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
expect(screen.queryByText("Loading…")).not.toBeInTheDocument();
});
}
async function openNodeRouting() {
await ready();
await userEvent.click(screen.getAllByText("Node Routing")[0]);
}
describe("SettingsModal Node Routing section", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchSettings.mockResolvedValue(baseSettings);
mockFetchSettingsByScope.mockResolvedValue({ global: baseSettings, project: {} });
mockFetchAuthStatus.mockResolvedValue({ providers: [] });
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
mockFetchBackups.mockResolvedValue({ backups: [], totalSize: 0 });
mockFetchMemoryFiles.mockResolvedValue({ files: [] });
mockFetchMemoryFile.mockResolvedValue({ path: ".fusion/memory/MEMORY.md", content: "" });
mockFetchGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 10 });
mockFetchMemoryBackendStatus.mockResolvedValue({ currentBackend: "file", capabilities: { readable: true, writable: true, supportsAtomicWrite: true, hasConflictResolution: false, persistent: true }, availableBackends: ["file"], qmdAvailable: false, qmdInstallCommand: null });
mockUseMemoryBackendStatus.mockReturnValue({ status: null, currentBackend: "file", capabilities: { readable: true, writable: true, supportsAtomicWrite: true, hasConflictResolution: false, persistent: true }, availableBackends: ["file"], loading: false, error: null, refresh: vi.fn() });
mockFetchGitRemotesDetailed.mockResolvedValue([]);
mockFetchDashboardHealth.mockResolvedValue({ status: "ok", version: "1.2.3", uptime: 123 });
mockCheckForUpdates.mockResolvedValue(undefined);
mockFetchRemoteSettings.mockResolvedValue({ settings: {} });
mockUpdateSettings.mockResolvedValue({ success: true });
});
it("renders section heading and description", async () => {
renderModal();
await openNodeRouting();
expect(screen.getByRole("heading", { name: "Node Routing" })).toBeInTheDocument();
expect(screen.getByText(/Configure how tasks are routed to execution nodes/)).toBeInTheDocument();
});
it("shows project scope banner", async () => {
renderModal();
await openNodeRouting();
expect(screen.getByText(/These settings only affect this project\./i)).toBeInTheDocument();
});
it("shows local execution selected when no default node is set", async () => {
renderModal();
await openNodeRouting();
expect(screen.getByLabelText("Default Execution Node")).toHaveValue("");
expect(screen.getByRole("option", { name: "Local execution (no default node)" })).toBeInTheDocument();
});
it("shows current node when defaultNodeId is configured", async () => {
mockFetchSettings.mockResolvedValue({ ...baseSettings, defaultNodeId: "node-remote-1" });
renderModal();
await openNodeRouting();
expect(screen.getByLabelText("Default Execution Node")).toHaveValue("node-remote-1");
expect(screen.getByText(/Selected node: Online/i)).toBeInTheDocument();
});
it("lists all available nodes in selector", async () => {
renderModal();
await openNodeRouting();
expect(screen.getByRole("option", { name: /Local Machine \(Online\)/ })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /Alpha Worker \(Online\)/ })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /Beta Node \(Offline\)/ })).toBeInTheDocument();
});
it("changing default node updates form", async () => {
renderModal();
await openNodeRouting();
fireEvent.change(screen.getByLabelText("Default Execution Node"), { target: { value: "node-remote-1" } });
expect(screen.getByLabelText("Default Execution Node")).toHaveValue("node-remote-1");
});
it("defaults unavailable node policy to block", async () => {
renderModal();
await openNodeRouting();
expect(screen.getByLabelText("Unavailable Node Policy")).toHaveValue("block");
});
it("shows fallback-local when configured", async () => {
mockFetchSettings.mockResolvedValue({ ...baseSettings, unavailableNodePolicy: "fallback-local" });
renderModal();
await openNodeRouting();
expect(screen.getByLabelText("Unavailable Node Policy")).toHaveValue("fallback-local");
});
it("changing policy updates form", async () => {
renderModal();
await openNodeRouting();
fireEvent.change(screen.getByLabelText("Unavailable Node Policy"), { target: { value: "fallback-local" } });
expect(screen.getByLabelText("Unavailable Node Policy")).toHaveValue("fallback-local");
});
it("save persists both defaultNodeId and unavailableNodePolicy", async () => {
renderModal();
await openNodeRouting();
fireEvent.change(screen.getByLabelText("Default Execution Node"), { target: { value: "node-remote-1" } });
fireEvent.change(screen.getByLabelText("Unavailable Node Policy"), { target: { value: "fallback-local" } });
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalledTimes(1));
const payload = mockUpdateSettings.mock.calls[0][0];
expect(payload).toMatchObject({
defaultNodeId: "node-remote-1",
unavailableNodePolicy: "fallback-local",
});
});
it.each([
["block", "Block execution"],
["fallback-local", "Fall back to local"],
])("renders policy option %s (%s)", async (_value, label) => {
renderModal();
await openNodeRouting();
expect(screen.getByRole("option", { name: label })).toBeInTheDocument();
});
it("removes routing controls from scheduling section", async () => {
renderModal();
await ready();
await userEvent.click(screen.getByRole("button", { name: "Scheduling" }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Scheduling" })).toBeInTheDocument();
});
expect(screen.queryByLabelText("Default Execution Node")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Unavailable Node Policy")).not.toBeInTheDocument();
});
it("shows informational note", async () => {
renderModal();
await openNodeRouting();
expect(screen.getByText(/These settings apply at the project level/)).toBeInTheDocument();
});
});