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:
5
.changeset/node-routing-policy.md
Normal file
5
.changeset/node-routing-policy.md
Normal 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.
|
||||
@@ -218,10 +218,39 @@
|
||||
.inline-create-node-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
||||
@@ -45,9 +45,17 @@ interface InlineCreateCardProps {
|
||||
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 "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 {
|
||||
return provider && modelId ? `${provider}/${modelId}` : "";
|
||||
}
|
||||
@@ -846,6 +854,16 @@ export function InlineCreateCard({
|
||||
</option>
|
||||
))}
|
||||
</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>
|
||||
|
||||
<div className="agent-trigger-wrap" ref={agentPickerRef}>
|
||||
|
||||
@@ -79,6 +79,40 @@
|
||||
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 {
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
|
||||
@@ -32,9 +32,17 @@ function isTaskActivelyExecuting(task: Task): boolean {
|
||||
function getNodeStatusLabel(status: "online" | "offline" | "connecting" | "error"): string {
|
||||
if (status === "online") return "Online";
|
||||
if (status === "connecting") return "Connecting";
|
||||
if (status === "error") return "Error";
|
||||
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 SortDirection = "asc" | "desc";
|
||||
|
||||
@@ -762,7 +770,7 @@ export function ListView({
|
||||
onToggleModelFavorite={onToggleModelFavorite}
|
||||
/>
|
||||
</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)}>
|
||||
<option value="__no_change__">Node: No change</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>
|
||||
))}
|
||||
</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>
|
||||
<button
|
||||
className="btn btn-primary btn-sm bulk-edit-apply-btn"
|
||||
|
||||
@@ -120,10 +120,39 @@
|
||||
.quick-entry-node-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
||||
@@ -66,9 +66,17 @@ interface QuickEntryBoxProps {
|
||||
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 "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 {
|
||||
return provider && modelId ? `${provider}/${modelId}` : "";
|
||||
}
|
||||
@@ -1388,6 +1396,16 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
</option>
|
||||
))}
|
||||
</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>
|
||||
|
||||
<div className="agent-trigger-wrap" ref={agentPickerRef}>
|
||||
|
||||
@@ -1199,3 +1199,32 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -39,9 +39,17 @@ const GITHUB_STAR_CLICKED_KEY = "fusion:github-star-clicked";
|
||||
function getNodeStatusLabel(status: "online" | "offline" | "connecting" | "error"): string {
|
||||
if (status === "online") return "Online";
|
||||
if (status === "connecting") return "Connecting";
|
||||
if (status === "error") return "Error";
|
||||
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
|
||||
* the past? Used to permanently hide the button afterward — clicking opens
|
||||
@@ -2419,12 +2427,23 @@ export function SettingsModal({
|
||||
</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"
|
||||
}
|
||||
|
||||
@@ -9,9 +9,17 @@ import { Sparkles, ChevronUp, ChevronDown, X, Maximize2, Minimize2 } from "lucid
|
||||
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 "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"];
|
||||
|
||||
/** Renders a phase badge using shared .phase-badge classes for consistency */
|
||||
@@ -838,6 +846,16 @@ export function TaskForm({
|
||||
</option>
|
||||
))}
|
||||
</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>
|
||||
{nodeOverrideDisabledReason ?? "Task override takes priority over project default node routing."}
|
||||
</small>
|
||||
|
||||
@@ -1123,6 +1123,35 @@ body {
|
||||
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-group {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user