feat(FN-2727): add node routing controls to CLI and dashboard

- Add CLI support for task node routing with create --node, task set-node, and task clear-node commands
- Extend settings command validation/output for defaultNodeId and unavailableNodePolicy and document new keys
- Surface task routing details in CLI task show plus dashboard Routing tab status/binding reason indicators
- Enhance Settings modal node routing UX with selected-node health status and add/refresh tests and changeset
This commit is contained in:
Fusion
2026-04-28 14:14:18 -07:00
committed by gsxdsm
parent 437d5a51c1
commit fdeba4cf89
14 changed files with 511 additions and 20 deletions

View File

@@ -50,10 +50,13 @@
}
.routing-summary-value {
display: inline-flex;
display: flex;
flex-wrap: wrap;
min-width: 0;
align-items: center;
gap: var(--space-sm);
color: var(--text);
overflow-wrap: anywhere;
}
.routing-summary-warning {
@@ -65,6 +68,49 @@
color: var(--color-warning);
}
.routing-node-status {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
color: var(--text-muted);
}
.routing-node-status__dot {
width: var(--space-sm);
height: var(--space-sm);
border-radius: 50%;
background: var(--text-dim);
}
.routing-node-status--online .routing-node-status__dot {
background: var(--color-success);
}
.routing-node-status--offline .routing-node-status__dot,
.routing-node-status--error .routing-node-status__dot {
background: var(--color-error);
}
.routing-node-status--connecting .routing-node-status__dot {
background: var(--color-warning);
}
.routing-node-status--error {
color: var(--color-error);
}
.routing-node-status--connecting {
color: var(--color-warning);
}
.routing-node-status--online {
color: var(--color-success);
}
.routing-node-status--offline {
color: var(--text-muted);
}
.routing-tab__info-banner,
.routing-tab__warning-banner,
.routing-tab__error {

View File

@@ -13,12 +13,19 @@ interface RoutingTabProps {
onTaskUpdated?: (task: Task) => void;
}
const STATUS_DOT: Record<NodeInfo["status"], string> = {
online: "🟢",
offline: "🔴",
connecting: "🟡",
error: "🔴",
};
function getNodeStatusLabel(status: NodeInfo["status"]): string {
if (status === "online") return "Online";
if (status === "connecting") return "Connecting";
if (status === "error") return "Error";
return "Offline";
}
function getNodeStatusClass(status: NodeInfo["status"]): string {
if (status === "online") return "routing-node-status--online";
if (status === "connecting") return "routing-node-status--connecting";
if (status === "error") return "routing-node-status--error";
return "routing-node-status--offline";
}
type RoutingSettings = Settings & {
defaultNodeId?: string;
@@ -82,10 +89,14 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
const effectiveNode = effectiveNodeId ? nodesById.get(effectiveNodeId) : undefined;
const effectiveNodeName = effectiveNode
? `${STATUS_DOT[effectiveNode.status]} ${effectiveNode.name} (${effectiveNode.type})`
? `${effectiveNode.name} (${effectiveNode.type})`
: effectiveNodeId
? `${effectiveNodeId} (node unavailable or unknown)`
: "Local (no routing configured)";
const blockingReason =
(task as Task & { blockedReason?: string; statusReason?: string }).blockedReason
|| (task as Task & { statusReason?: string }).statusReason
|| "(not blocked)";
const taskInProgress = task.column === "in-progress";
const selectorDisabled = taskInProgress || savingNode || loadingNodes;
@@ -137,6 +148,12 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
<span className="routing-summary-label">Effective node</span>
<span className="routing-summary-value">
{effectiveNodeName}
{effectiveNode ? (
<span className={`routing-node-status ${getNodeStatusClass(effectiveNode.status)}`}>
<span className="routing-node-status__dot" aria-hidden="true" />
{getNodeStatusLabel(effectiveNode.status)}
</span>
) : null}
{isUnhealthy(effectiveNode?.status) ? (
<span className="routing-summary-warning">Unhealthy</span>
) : null}
@@ -150,6 +167,10 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
<span className="routing-summary-label">Unavailable-node policy</span>
<span className="routing-summary-value">{getRoutingPolicyLabel(routingSettings?.unavailableNodePolicy)}</span>
</div>
<div className="routing-summary-row" role="listitem">
<span className="routing-summary-label">Blocking reason</span>
<span className="routing-summary-value">{blockingReason}</span>
</div>
</div>
{taskInProgress && effectiveNodeId ? (
<div className="routing-tab__info-banner">
@@ -181,7 +202,7 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
<option value="">Use project default</option>
{sortedNodes.map((node) => (
<option key={node.id} value={node.id}>
{STATUS_DOT[node.status]} {node.name} ({node.type})
{node.name} ({node.type}) {getNodeStatusLabel(node.status)}
</option>
))}
</select>

View File

@@ -1522,3 +1522,32 @@
.settings-node-status--connecting .settings-node-status__dot {
background: var(--color-warning);
}
.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);
}

View File

@@ -2,8 +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 type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } 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, 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";
import type { ToastType } from "../hooks/useToast";

View File

@@ -76,6 +76,7 @@ describe("RoutingTab", () => {
expect(await screen.findByText("Per-task override")).toBeInTheDocument();
expect(screen.getByText(/Effective node/i)).toBeInTheDocument();
expect(screen.getByText("Online")).toBeInTheDocument();
});
it("renders routing summary with project default", async () => {
@@ -96,6 +97,7 @@ describe("RoutingTab", () => {
expect(await screen.findByText("Local (no routing configured)")).toBeInTheDocument();
expect(screen.getByText("No routing")).toBeInTheDocument();
expect(screen.getByText("(not blocked)")).toBeInTheDocument();
});
it.each([

View File

@@ -1757,6 +1757,57 @@ describe("SettingsModal", () => {
});
describe("memory dream trigger", () => {
const openMemorySection = async () => {
const [memorySectionButton] = await screen.findAllByRole("button", { name: /^Memory$/i });
await userEvent.click(memorySectionButton);
};
it("shows Dream Now button when dreams are enabled", async () => {
mockFetchSettings.mockResolvedValueOnce({
...defaultSettings,
memoryEnabled: true,
memoryDreamsEnabled: true,
memoryDreamsSchedule: "0 4 * * *",
});
renderModal();
await waitForSettingsModalReady();
await openMemorySection();
expect(await screen.findByRole("button", { name: "Dream Now" })).toBeInTheDocument();
});
it("triggers dream processing from Dream Now button", async () => {
const addToast = vi.fn();
mockFetchSettings.mockResolvedValueOnce({
...defaultSettings,
memoryEnabled: true,
memoryDreamsEnabled: true,
});
mockTriggerMemoryDreams.mockResolvedValueOnce({ success: true, summary: "done" });
renderModal({ addToast });
await waitForSettingsModalReady();
await openMemorySection();
await userEvent.click(await screen.findByRole("button", { name: "Dream Now" }));
await waitFor(() => {
expect(mockTriggerMemoryDreams).toHaveBeenCalledWith(undefined);
});
expect(addToast).toHaveBeenCalledWith("Dream processing completed", "success");
});
it("hides Dream Now button when dreams are disabled", async () => {
renderModal();
await waitForSettingsModalReady();
await openMemorySection();
expect(screen.queryByRole("button", { name: "Dream Now" })).not.toBeInTheDocument();
});
});
describe("memory dream trigger", () => {
const openMemorySection = async () => {
const [memorySectionButton] = await screen.findAllByRole("button", { name: /^Memory$/i });