feat(FN-2723): show node health in dashboard routing selectors

- Add node status indicators and labels to task creation, quick entry, task form, and settings node selectors
- Surface selected node health in list bulk-edit controls with status dot styling for online/connecting/offline/error states
- Update dashboard component CSS and shared styles for consistent node status presentation
- Include changeset documenting unavailable-node routing policy and default node behavior
This commit is contained in:
Fusion
2026-04-28 08:38:57 -07:00
committed by gsxdsm
parent 0975e24512
commit 81c6f01e9e
11 changed files with 249 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add node routing policy enforcement: when a task is routed to a node that is offline or unhealthy, the project's `unavailableNodePolicy` setting controls whether execution is blocked (task stays in todo) or falls back to local execution. Supports `defaultNodeId` project setting for pinned default nodes and per-task `nodeId` overrides. Routing decisions are logged to task activity for visibility.

View File

@@ -218,10 +218,39 @@
.inline-create-node-wrap { .inline-create-node-wrap {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-sm);
} }
.inline-create-node-select { .inline-create-node-select {
max-width: 250px; max-width: calc(var(--space-2xl) * 8);
}
.inline-create-node-status {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-size: 12px;
color: var(--text-muted);
}
.inline-create-node-status__dot {
width: var(--space-sm);
height: var(--space-sm);
border-radius: var(--radius-pill);
background: var(--color-muted);
}
.inline-create-node-status--online .inline-create-node-status__dot {
background: var(--color-success);
}
.inline-create-node-status--offline .inline-create-node-status__dot,
.inline-create-node-status--error .inline-create-node-status__dot {
background: var(--color-error);
}
.inline-create-node-status--connecting .inline-create-node-status__dot {
background: var(--color-warning);
} }
.inline-create-priority-select { .inline-create-priority-select {

View File

@@ -45,9 +45,17 @@ interface InlineCreateCardProps {
function getNodeStatusLabel(status: NodeInfo["status"]): string { function getNodeStatusLabel(status: NodeInfo["status"]): string {
if (status === "online") return "Online"; if (status === "online") return "Online";
if (status === "connecting") return "Connecting"; if (status === "connecting") return "Connecting";
if (status === "error") return "Error";
return "Offline"; return "Offline";
} }
function getNodeStatusClass(status: NodeInfo["status"]): string {
if (status === "online") return "inline-create-node-status--online";
if (status === "connecting") return "inline-create-node-status--connecting";
if (status === "error") return "inline-create-node-status--error";
return "inline-create-node-status--offline";
}
function getModelSelectionValue(provider?: string, modelId?: string): string { function getModelSelectionValue(provider?: string, modelId?: string): string {
return provider && modelId ? `${provider}/${modelId}` : ""; return provider && modelId ? `${provider}/${modelId}` : "";
} }
@@ -846,6 +854,16 @@ export function InlineCreateCard({
</option> </option>
))} ))}
</select> </select>
{(() => {
const selectedNode = nodes.find((node) => node.id === nodeId);
if (!selectedNode) return null;
return (
<span className={`inline-create-node-status ${getNodeStatusClass(selectedNode.status)}`}>
<span className="inline-create-node-status__dot" aria-hidden="true" />
{getNodeStatusLabel(selectedNode.status)}
</span>
);
})()}
</label> </label>
<div className="agent-trigger-wrap" ref={agentPickerRef}> <div className="agent-trigger-wrap" ref={agentPickerRef}>

View File

@@ -79,6 +79,40 @@
min-width: 180px; min-width: 180px;
} }
.bulk-edit-node-wrap {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
}
.list-view-node-status {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-size: 12px;
color: var(--text-muted);
}
.list-view-node-status__dot {
width: var(--space-sm);
height: var(--space-sm);
border-radius: var(--radius-pill);
background: var(--color-muted);
}
.list-view-node-status--online .list-view-node-status__dot {
background: var(--color-success);
}
.list-view-node-status--offline .list-view-node-status__dot,
.list-view-node-status--error .list-view-node-status__dot {
background: var(--color-error);
}
.list-view-node-status--connecting .list-view-node-status__dot {
background: var(--color-warning);
}
.bulk-edit-dropdown .model-combobox-trigger { .bulk-edit-dropdown .model-combobox-trigger {
font-size: 12px; font-size: 12px;
padding: 6px 10px; padding: 6px 10px;

View File

@@ -32,9 +32,17 @@ function isTaskActivelyExecuting(task: Task): boolean {
function getNodeStatusLabel(status: "online" | "offline" | "connecting" | "error"): string { function getNodeStatusLabel(status: "online" | "offline" | "connecting" | "error"): string {
if (status === "online") return "Online"; if (status === "online") return "Online";
if (status === "connecting") return "Connecting"; if (status === "connecting") return "Connecting";
if (status === "error") return "Error";
return "Offline"; return "Offline";
} }
function getNodeStatusClass(status: "online" | "offline" | "connecting" | "error"): string {
if (status === "online") return "list-view-node-status--online";
if (status === "connecting") return "list-view-node-status--connecting";
if (status === "error") return "list-view-node-status--error";
return "list-view-node-status--offline";
}
type SortField = "id" | "title" | "status" | "column"; type SortField = "id" | "title" | "status" | "column";
type SortDirection = "asc" | "desc"; type SortDirection = "asc" | "desc";
@@ -762,7 +770,7 @@ export function ListView({
onToggleModelFavorite={onToggleModelFavorite} onToggleModelFavorite={onToggleModelFavorite}
/> />
</div> </div>
<div className="bulk-edit-dropdown"> <div className="bulk-edit-dropdown bulk-edit-node-wrap">
<select className="select" value={bulkNodeId} onChange={(e) => setBulkNodeId(e.target.value)}> <select className="select" value={bulkNodeId} onChange={(e) => setBulkNodeId(e.target.value)}>
<option value="__no_change__">Node: No change</option> <option value="__no_change__">Node: No change</option>
<option value="">Node: Clear override</option> <option value="">Node: Clear override</option>
@@ -770,6 +778,16 @@ export function ListView({
<option key={node.id} value={node.id}>{node.name} ({getNodeStatusLabel(node.status)})</option> <option key={node.id} value={node.id}>{node.name} ({getNodeStatusLabel(node.status)})</option>
))} ))}
</select> </select>
{(() => {
const selectedNode = nodes.find((node) => node.id === bulkNodeId);
if (!selectedNode) return null;
return (
<span className={`list-view-node-status ${getNodeStatusClass(selectedNode.status)}`}>
<span className="list-view-node-status__dot" aria-hidden="true" />
{getNodeStatusLabel(selectedNode.status)}
</span>
);
})()}
</div> </div>
<button <button
className="btn btn-primary btn-sm bulk-edit-apply-btn" className="btn btn-primary btn-sm bulk-edit-apply-btn"

View File

@@ -120,10 +120,39 @@
.quick-entry-node-wrap { .quick-entry-node-wrap {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-sm);
} }
.quick-entry-node-select { .quick-entry-node-select {
max-width: 250px; max-width: calc(var(--space-2xl) * 8);
}
.quick-entry-node-status {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-size: 12px;
color: var(--text-muted);
}
.quick-entry-node-status__dot {
width: var(--space-sm);
height: var(--space-sm);
border-radius: var(--radius-pill);
background: var(--color-muted);
}
.quick-entry-node-status--online .quick-entry-node-status__dot {
background: var(--color-success);
}
.quick-entry-node-status--offline .quick-entry-node-status__dot,
.quick-entry-node-status--error .quick-entry-node-status__dot {
background: var(--color-error);
}
.quick-entry-node-status--connecting .quick-entry-node-status__dot {
background: var(--color-warning);
} }
.quick-entry-subtasks-toggle { .quick-entry-subtasks-toggle {

View File

@@ -66,9 +66,17 @@ interface QuickEntryBoxProps {
function getNodeStatusLabel(status: NodeInfo["status"]): string { function getNodeStatusLabel(status: NodeInfo["status"]): string {
if (status === "online") return "Online"; if (status === "online") return "Online";
if (status === "connecting") return "Connecting"; if (status === "connecting") return "Connecting";
if (status === "error") return "Error";
return "Offline"; return "Offline";
} }
function getNodeStatusClass(status: NodeInfo["status"]): string {
if (status === "online") return "quick-entry-node-status--online";
if (status === "connecting") return "quick-entry-node-status--connecting";
if (status === "error") return "quick-entry-node-status--error";
return "quick-entry-node-status--offline";
}
function getModelSelectionValue(provider?: string, modelId?: string): string { function getModelSelectionValue(provider?: string, modelId?: string): string {
return provider && modelId ? `${provider}/${modelId}` : ""; return provider && modelId ? `${provider}/${modelId}` : "";
} }
@@ -1388,6 +1396,16 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
</option> </option>
))} ))}
</select> </select>
{(() => {
const selectedNode = nodes.find((node) => node.id === nodeId);
if (!selectedNode) return null;
return (
<span className={`quick-entry-node-status ${getNodeStatusClass(selectedNode.status)}`}>
<span className="quick-entry-node-status__dot" aria-hidden="true" />
{getNodeStatusLabel(selectedNode.status)}
</span>
);
})()}
</label> </label>
<div className="agent-trigger-wrap" ref={agentPickerRef}> <div className="agent-trigger-wrap" ref={agentPickerRef}>

View File

@@ -1199,3 +1199,32 @@
gap: var(--space-xs); gap: var(--space-xs);
} }
} }
.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

@@ -39,9 +39,17 @@ const GITHUB_STAR_CLICKED_KEY = "fusion:github-star-clicked";
function getNodeStatusLabel(status: "online" | "offline" | "connecting" | "error"): string { function getNodeStatusLabel(status: "online" | "offline" | "connecting" | "error"): string {
if (status === "online") return "Online"; if (status === "online") return "Online";
if (status === "connecting") return "Connecting"; if (status === "connecting") return "Connecting";
if (status === "error") return "Error";
return "Offline"; return "Offline";
} }
function getNodeStatusClass(status: "online" | "offline" | "connecting" | "error"): string {
if (status === "online") return "settings-node-status--online";
if (status === "connecting") return "settings-node-status--connecting";
if (status === "error") return "settings-node-status--error";
return "settings-node-status--offline";
}
/** /**
* Has the user already clicked the "Star on GitHub" button at any point in * Has the user already clicked the "Star on GitHub" button at any point in
* the past? Used to permanently hide the button afterward — clicking opens * the past? Used to permanently hide the button afterward — clicking opens
@@ -2419,12 +2427,23 @@ export function SettingsModal({
</option> </option>
))} ))}
</select> </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> <small>Used when a task has no node override. Node status is shown for safer routing selection.</small>
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="unavailableNodePolicy">Unavailable Node Policy</label> <label htmlFor="unavailableNodePolicy">Unavailable Node Policy</label>
<select <select
id="unavailableNodePolicy" id="unavailableNodePolicy"
className="select"
value={ value={
form.unavailableNodePolicy === "fallback-local" ? "fallback-local" : "block" form.unavailableNodePolicy === "fallback-local" ? "fallback-local" : "block"
} }

View File

@@ -9,9 +9,17 @@ import { Sparkles, ChevronUp, ChevronDown, X, Maximize2, Minimize2 } from "lucid
function getNodeStatusLabel(status: NodeInfo["status"]): string { function getNodeStatusLabel(status: NodeInfo["status"]): string {
if (status === "online") return "Online"; if (status === "online") return "Online";
if (status === "connecting") return "Connecting"; if (status === "connecting") return "Connecting";
if (status === "error") return "Error";
return "Offline"; return "Offline";
} }
function getNodeStatusClass(status: NodeInfo["status"]): string {
if (status === "online") return "task-form-node-status--online";
if (status === "connecting") return "task-form-node-status--connecting";
if (status === "error") return "task-form-node-status--error";
return "task-form-node-status--offline";
}
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
/** Renders a phase badge using shared .phase-badge classes for consistency */ /** Renders a phase badge using shared .phase-badge classes for consistency */
@@ -838,6 +846,16 @@ export function TaskForm({
</option> </option>
))} ))}
</select> </select>
{(() => {
const selectedNode = (nodeOptions ?? []).find((node) => node.id === nodeId);
if (!selectedNode) return null;
return (
<div className={`task-form-node-status ${getNodeStatusClass(selectedNode.status)}`}>
<span className="task-form-node-status__dot" aria-hidden="true" />
<span>{`Selected node: ${getNodeStatusLabel(selectedNode.status)}`}</span>
</div>
);
})()}
<small> <small>
{nodeOverrideDisabledReason ?? "Task override takes priority over project default node routing."} {nodeOverrideDisabledReason ?? "Task override takes priority over project default node routing."}
</small> </small>

View File

@@ -1123,6 +1123,35 @@ body {
font-weight: 500; font-weight: 500;
} }
.task-form-node-status {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
margin-top: var(--space-sm);
font-size: 12px;
color: var(--text-muted);
}
.task-form-node-status__dot {
width: var(--space-sm);
height: var(--space-sm);
border-radius: var(--radius-pill);
background: var(--color-muted);
}
.task-form-node-status--online .task-form-node-status__dot {
background: var(--color-success);
}
.task-form-node-status--offline .task-form-node-status__dot,
.task-form-node-status--error .task-form-node-status__dot {
background: var(--color-error);
}
.task-form-node-status--connecting .task-form-node-status__dot {
background: var(--color-warning);
}
/* === Heartbeat Multiplier === */ /* === Heartbeat Multiplier === */
.heartbeat-multiplier-group { .heartbeat-multiplier-group {
display: flex; display: flex;