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) => (
+ setMode(value)}
+ style={{ flex: 1 }}
+ >{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.'}
+
Cancel
- Save
+ Save
)
@@ -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)}
/>
- {
+ {
e.stopPropagation()
- const key = document.getElementById(`label-key-${node.id}`).value.trim()
- const val = document.getElementById(`label-value-${node.id}`).value.trim()
- if (key) handleAddLabel(node.id, key, val)
+ const { key = '', value: val = '' } = labelInputs[node.id] || {}
+ if (key.trim()) {
+ await handleAddLabel(node.id, key.trim(), val.trim())
+ setLabelInputs(prev => ({ ...prev, [node.id]: { key: '', value: '' } }))
+ }
}}>Add
@@ -932,15 +985,28 @@ export default function Nodes() {
Model
+ Mode
Node Selector
Min Replicas
Max Replicas
Actions
- {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 (
{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') : '-'}
+
{
try {
@@ -969,7 +1039,8 @@ export default function Nodes() {
}}>
- ))}
+ )
+ })}
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
}