From b4fff9293d60eead9597fda749a73f6804ab713e Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 31 Mar 2026 08:41:40 +0000 Subject: [PATCH] chore: small ui improvements in the node page Signed-off-by: Ettore Di Giacinto --- core/http/react-ui/src/pages/Nodes.jsx | 157 ++++++++++++++++++------- core/services/nodes/registry.go | 4 +- 2 files changed, 116 insertions(+), 45 deletions(-) diff --git a/core/http/react-ui/src/pages/Nodes.jsx b/core/http/react-ui/src/pages/Nodes.jsx index 0ec97a7c8..1ecd5cf57 100644 --- a/core/http/react-ui/src/pages/Nodes.jsx +++ b/core/http/react-ui/src/pages/Nodes.jsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, Fragment } from 'react' import { useOutletContext, useNavigate } from 'react-router-dom' import { nodesApi } from '../utils/api' +import { useModels } from '../hooks/useModels' import LoadingSpinner from '../components/LoadingSpinner' import ConfirmDialog from '../components/ConfirmDialog' import ImageSelector, { useImageSelector, dockerImage, dockerFlags } from '../components/ImageSelector' @@ -37,6 +38,7 @@ function gpuVendorLabel(vendor) { const statusConfig = { healthy: { color: 'var(--color-success)', label: 'Healthy' }, unhealthy: { color: 'var(--color-error)', label: 'Unhealthy' }, + offline: { color: 'var(--color-error)', label: 'Offline' }, registering: { color: 'var(--color-primary)', label: 'Registering' }, draining: { color: 'var(--color-warning)', label: 'Draining' }, pending: { color: 'var(--color-warning)', label: 'Pending Approval' }, @@ -160,56 +162,90 @@ function WorkerHintCard({ addToast, activeTab, hasWorkers }) { } function SchedulingForm({ onSave, onCancel }) { + const [mode, setMode] = useState('placement') const [modelName, setModelName] = useState('') const [selectorText, setSelectorText] = useState('') - const [minReplicas, setMinReplicas] = useState(0) + const [minReplicas, setMinReplicas] = useState(1) const [maxReplicas, setMaxReplicas] = useState(0) + const { models } = useModels() + + const parseSelector = () => { + if (!selectorText.trim()) return null + const pairs = {} + selectorText.split(',').forEach(p => { + const [k, v] = p.split('=').map(s => s.trim()) + if (k) pairs[k] = v || '' + }) + return Object.keys(pairs).length > 0 ? pairs : null + } + + const isValid = () => { + if (!modelName) return false + if (mode === 'placement') return !!parseSelector() + return minReplicas > 0 || maxReplicas > 0 + } const handleSubmit = () => { - let nodeSelector = null - if (selectorText.trim()) { - const pairs = {} - selectorText.split(',').forEach(p => { - const [k, v] = p.split('=').map(s => s.trim()) - if (k) pairs[k] = v || '' - }) - nodeSelector = pairs - } + const nodeSelector = parseSelector() onSave({ model_name: modelName, - node_selector: nodeSelector ? JSON.stringify(nodeSelector) : '', - min_replicas: minReplicas, - max_replicas: maxReplicas, + node_selector: nodeSelector || undefined, + min_replicas: mode === 'placement' ? 0 : minReplicas, + max_replicas: mode === 'placement' ? 0 : maxReplicas, }) } + const modeBtn = (value, label) => ( + + ) + return (
+
+ {modeBtn('placement', 'Node Placement')} + {modeBtn('autoscaling', 'Auto-Scaling')} +
+

+ {mode === 'placement' + ? 'Constrain which nodes this model can run on. Model loads on-demand and can be evicted when idle.' + : 'Automatically maintain replica counts across nodes. Models with min \u2265 1 are protected from eviction.'} +

- - setModelName(e.target.value)} - placeholder="e.g. llama3" style={{ width: '100%' }} /> + +
- - setSelectorText(e.target.value)} - placeholder="e.g. gpu.vendor=nvidia,tier=fast" style={{ width: '100%' }} /> -
-
- - setMinReplicas(parseInt(e.target.value) || 0)} - style={{ width: '100%' }} /> -
-
- - setMaxReplicas(parseInt(e.target.value) || 0)} - style={{ width: '100%' }} /> + + setSelectorText(e.target.value)} + placeholder="e.g. gpu.vendor=nvidia,tier=fast" /> + {mode === 'autoscaling' && !selectorText.trim() && ( + Empty = all nodes + )}
+ {mode === 'autoscaling' && <> +
+ + setMinReplicas(parseInt(e.target.value) || 0)} /> +
+
+ + setMaxReplicas(parseInt(e.target.value) || 0)} /> +
+ }
- +
) @@ -229,6 +265,13 @@ export default function Nodes() { const [activeTab, setActiveTab] = useState('backend') // 'backend', 'agent', or 'scheduling' const [schedulingConfigs, setSchedulingConfigs] = useState([]) const [showSchedulingForm, setShowSchedulingForm] = useState(false) + const [labelInputs, setLabelInputs] = useState({}) + + const setLabelInput = (nodeId, field, val) => + setLabelInputs(prev => ({ + ...prev, + [nodeId]: { ...(prev[nodeId] || { key: '', value: '' }), [field]: val } + })) const fetchNodes = useCallback(async () => { try { @@ -472,7 +515,7 @@ export default function Nodes() { // Compute stats for current tab const total = filteredNodes.length const healthy = filteredNodes.filter(n => n.status === 'healthy').length - const unhealthy = filteredNodes.filter(n => n.status === 'unhealthy').length + const unhealthy = filteredNodes.filter(n => n.status === 'unhealthy' || n.status === 'offline').length const draining = filteredNodes.filter(n => n.status === 'draining').length const pending = filteredNodes.filter(n => n.status === 'pending').length @@ -878,18 +921,28 @@ export default function Nodes() { {/* Add label form */}
setLabelInput(node.id, 'key', e.target.value)} /> setLabelInput(node.id, 'value', e.target.value)} /> -
@@ -932,15 +985,28 @@ export default function Nodes() { + - {schedulingConfigs.map(cfg => ( + {schedulingConfigs.map(cfg => { + const isAutoScaling = cfg.min_replicas > 0 || cfg.max_replicas > 0 + const hasSelector = !!cfg.node_selector + const modeLabel = isAutoScaling ? 'Auto-scaling' : hasSelector ? 'Placement' : 'Inactive' + const modeColor = isAutoScaling ? 'var(--color-success)' : hasSelector ? 'var(--color-primary)' : 'var(--color-text-muted)' + return ( + - - + + - ))} + ) + })}
ModelMode Node Selector Min Replicas Max Replicas Actions
{cfg.model_name} + {modeLabel} + {cfg.node_selector ? (() => { try { @@ -955,8 +1021,12 @@ export default function Nodes() { } catch { return {cfg.node_selector} } })() : Any node} {cfg.min_replicas || '-'}{cfg.max_replicas || 'unlimited'} + {isAutoScaling ? cfg.min_replicas : '-'} + + {isAutoScaling ? (cfg.max_replicas || 'no limit') : '-'} +
diff --git a/core/services/nodes/registry.go b/core/services/nodes/registry.go index 5d7404f5f..533c57ea5 100644 --- a/core/services/nodes/registry.go +++ b/core/services/nodes/registry.go @@ -802,7 +802,7 @@ func (r *NodeRegistry) GetModelScheduling(ctx context.Context, modelName string) // ListModelSchedulings returns all scheduling configs. func (r *NodeRegistry) ListModelSchedulings(ctx context.Context) ([]ModelSchedulingConfig, error) { var configs []ModelSchedulingConfig - err := r.db.WithContext(ctx).Find(&configs).Error + err := r.db.WithContext(ctx).Order("model_name ASC").Find(&configs).Error return configs, err } @@ -831,7 +831,7 @@ func (r *NodeRegistry) CountLoadedReplicas(ctx context.Context, modelName string func (r *NodeRegistry) ListWithExtras(ctx context.Context) ([]NodeWithExtras, error) { // Get all nodes var nodes []BackendNode - if err := r.db.WithContext(ctx).Find(&nodes).Error; err != nil { + if err := r.db.WithContext(ctx).Order("name ASC").Find(&nodes).Error; err != nil { return nil, err }