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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user