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:
25
packages/dashboard/app/components/ProjectNodeSelector.css
Normal file
25
packages/dashboard/app/components/ProjectNodeSelector.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user