Skip to content

Commit b4fff92

Browse files
committed
chore: small ui improvements in the node page
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 8180221 commit b4fff92

2 files changed

Lines changed: 116 additions & 45 deletions

File tree

core/http/react-ui/src/pages/Nodes.jsx

Lines changed: 114 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState, useEffect, useCallback, Fragment } from 'react'
22
import { useOutletContext, useNavigate } from 'react-router-dom'
33
import { nodesApi } from '../utils/api'
4+
import { useModels } from '../hooks/useModels'
45
import LoadingSpinner from '../components/LoadingSpinner'
56
import ConfirmDialog from '../components/ConfirmDialog'
67
import ImageSelector, { useImageSelector, dockerImage, dockerFlags } from '../components/ImageSelector'
@@ -37,6 +38,7 @@ function gpuVendorLabel(vendor) {
3738
const statusConfig = {
3839
healthy: { color: 'var(--color-success)', label: 'Healthy' },
3940
unhealthy: { color: 'var(--color-error)', label: 'Unhealthy' },
41+
offline: { color: 'var(--color-error)', label: 'Offline' },
4042
registering: { color: 'var(--color-primary)', label: 'Registering' },
4143
draining: { color: 'var(--color-warning)', label: 'Draining' },
4244
pending: { color: 'var(--color-warning)', label: 'Pending Approval' },
@@ -160,56 +162,90 @@ function WorkerHintCard({ addToast, activeTab, hasWorkers }) {
160162
}
161163

162164
function SchedulingForm({ onSave, onCancel }) {
165+
const [mode, setMode] = useState('placement')
163166
const [modelName, setModelName] = useState('')
164167
const [selectorText, setSelectorText] = useState('')
165-
const [minReplicas, setMinReplicas] = useState(0)
168+
const [minReplicas, setMinReplicas] = useState(1)
166169
const [maxReplicas, setMaxReplicas] = useState(0)
170+
const { models } = useModels()
171+
172+
const parseSelector = () => {
173+
if (!selectorText.trim()) return null
174+
const pairs = {}
175+
selectorText.split(',').forEach(p => {
176+
const [k, v] = p.split('=').map(s => s.trim())
177+
if (k) pairs[k] = v || ''
178+
})
179+
return Object.keys(pairs).length > 0 ? pairs : null
180+
}
181+
182+
const isValid = () => {
183+
if (!modelName) return false
184+
if (mode === 'placement') return !!parseSelector()
185+
return minReplicas > 0 || maxReplicas > 0
186+
}
167187

168188
const handleSubmit = () => {
169-
let nodeSelector = null
170-
if (selectorText.trim()) {
171-
const pairs = {}
172-
selectorText.split(',').forEach(p => {
173-
const [k, v] = p.split('=').map(s => s.trim())
174-
if (k) pairs[k] = v || ''
175-
})
176-
nodeSelector = pairs
177-
}
189+
const nodeSelector = parseSelector()
178190
onSave({
179191
model_name: modelName,
180-
node_selector: nodeSelector ? JSON.stringify(nodeSelector) : '',
181-
min_replicas: minReplicas,
182-
max_replicas: maxReplicas,
192+
node_selector: nodeSelector || undefined,
193+
min_replicas: mode === 'placement' ? 0 : minReplicas,
194+
max_replicas: mode === 'placement' ? 0 : maxReplicas,
183195
})
184196
}
185197

198+
const modeBtn = (value, label) => (
199+
<button
200+
className={`btn btn-sm ${mode === value ? 'btn-primary' : 'btn-secondary'}`}
201+
onClick={() => setMode(value)}
202+
style={{ flex: 1 }}
203+
>{label}</button>
204+
)
205+
186206
return (
187207
<div className="card" style={{ padding: 'var(--spacing-md)', marginBottom: 'var(--spacing-md)' }}>
208+
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', marginBottom: 'var(--spacing-sm)' }}>
209+
{modeBtn('placement', 'Node Placement')}
210+
{modeBtn('autoscaling', 'Auto-Scaling')}
211+
</div>
212+
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', margin: '0 0 var(--spacing-sm) 0' }}>
213+
{mode === 'placement'
214+
? 'Constrain which nodes this model can run on. Model loads on-demand and can be evicted when idle.'
215+
: 'Automatically maintain replica counts across nodes. Models with min \u2265 1 are protected from eviction.'}
216+
</p>
188217
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--spacing-sm)' }}>
189218
<div>
190-
<label style={{ fontSize: '0.75rem', fontWeight: 500 }}>Model Name</label>
191-
<input type="text" value={modelName} onChange={e => setModelName(e.target.value)}
192-
placeholder="e.g. llama3" style={{ width: '100%' }} />
193-
</div>
194-
<div>
195-
<label style={{ fontSize: '0.75rem', fontWeight: 500 }}>Node Selector (key=value, comma-separated)</label>
196-
<input type="text" value={selectorText} onChange={e => setSelectorText(e.target.value)}
197-
placeholder="e.g. gpu.vendor=nvidia,tier=fast" style={{ width: '100%' }} />
198-
</div>
199-
<div>
200-
<label style={{ fontSize: '0.75rem', fontWeight: 500 }}>Min Replicas (0 = no minimum)</label>
201-
<input type="number" min={0} value={minReplicas} onChange={e => setMinReplicas(parseInt(e.target.value) || 0)}
202-
style={{ width: '100%' }} />
219+
<label className="form-label">Model</label>
220+
<select className="input" value={modelName} onChange={e => setModelName(e.target.value)}>
221+
<option value="">Select a model...</option>
222+
{models.map(m => <option key={m.id} value={m.id}>{m.id}</option>)}
223+
</select>
203224
</div>
204225
<div>
205-
<label style={{ fontSize: '0.75rem', fontWeight: 500 }}>Max Replicas (0 = unlimited)</label>
206-
<input type="number" min={0} value={maxReplicas} onChange={e => setMaxReplicas(parseInt(e.target.value) || 0)}
207-
style={{ width: '100%' }} />
226+
<label className="form-label">Node Selector{mode === 'placement' ? '' : ' (optional)'}</label>
227+
<input className="input" type="text" value={selectorText} onChange={e => setSelectorText(e.target.value)}
228+
placeholder="e.g. gpu.vendor=nvidia,tier=fast" />
229+
{mode === 'autoscaling' && !selectorText.trim() && (
230+
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Empty = all nodes</span>
231+
)}
208232
</div>
233+
{mode === 'autoscaling' && <>
234+
<div>
235+
<label className="form-label">Min Replicas</label>
236+
<input className="input" type="number" min={0} value={minReplicas}
237+
onChange={e => setMinReplicas(parseInt(e.target.value) || 0)} />
238+
</div>
239+
<div>
240+
<label className="form-label">Max Replicas (0 = no limit)</label>
241+
<input className="input" type="number" min={0} value={maxReplicas}
242+
onChange={e => setMaxReplicas(parseInt(e.target.value) || 0)} />
243+
</div>
244+
</>}
209245
</div>
210246
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', marginTop: 'var(--spacing-sm)', justifyContent: 'flex-end' }}>
211247
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
212-
<button className="btn btn-primary btn-sm" onClick={handleSubmit} disabled={!modelName}>Save</button>
248+
<button className="btn btn-primary btn-sm" onClick={handleSubmit} disabled={!isValid()}>Save</button>
213249
</div>
214250
</div>
215251
)
@@ -229,6 +265,13 @@ export default function Nodes() {
229265
const [activeTab, setActiveTab] = useState('backend') // 'backend', 'agent', or 'scheduling'
230266
const [schedulingConfigs, setSchedulingConfigs] = useState([])
231267
const [showSchedulingForm, setShowSchedulingForm] = useState(false)
268+
const [labelInputs, setLabelInputs] = useState({})
269+
270+
const setLabelInput = (nodeId, field, val) =>
271+
setLabelInputs(prev => ({
272+
...prev,
273+
[nodeId]: { ...(prev[nodeId] || { key: '', value: '' }), [field]: val }
274+
}))
232275

233276
const fetchNodes = useCallback(async () => {
234277
try {
@@ -472,7 +515,7 @@ export default function Nodes() {
472515
// Compute stats for current tab
473516
const total = filteredNodes.length
474517
const healthy = filteredNodes.filter(n => n.status === 'healthy').length
475-
const unhealthy = filteredNodes.filter(n => n.status === 'unhealthy').length
518+
const unhealthy = filteredNodes.filter(n => n.status === 'unhealthy' || n.status === 'offline').length
476519
const draining = filteredNodes.filter(n => n.status === 'draining').length
477520
const pending = filteredNodes.filter(n => n.status === 'pending').length
478521

@@ -878,18 +921,28 @@ export default function Nodes() {
878921
{/* Add label form */}
879922
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', alignItems: 'center' }}>
880923
<input
881-
type="text" placeholder="key" style={{ width: 100, fontSize: '0.75rem' }}
882-
id={`label-key-${node.id}`}
924+
className="input"
925+
type="text"
926+
placeholder="key"
927+
style={{ width: '8rem' }}
928+
value={(labelInputs[node.id] || {}).key || ''}
929+
onChange={e => setLabelInput(node.id, 'key', e.target.value)}
883930
/>
884931
<input
885-
type="text" placeholder="value" style={{ width: 100, fontSize: '0.75rem' }}
886-
id={`label-value-${node.id}`}
932+
className="input"
933+
type="text"
934+
placeholder="value"
935+
style={{ width: '8rem' }}
936+
value={(labelInputs[node.id] || {}).value || ''}
937+
onChange={e => setLabelInput(node.id, 'value', e.target.value)}
887938
/>
888-
<button className="btn btn-secondary btn-sm" onClick={(e) => {
939+
<button className="btn btn-secondary btn-sm" onClick={async (e) => {
889940
e.stopPropagation()
890-
const key = document.getElementById(`label-key-${node.id}`).value.trim()
891-
const val = document.getElementById(`label-value-${node.id}`).value.trim()
892-
if (key) handleAddLabel(node.id, key, val)
941+
const { key = '', value: val = '' } = labelInputs[node.id] || {}
942+
if (key.trim()) {
943+
await handleAddLabel(node.id, key.trim(), val.trim())
944+
setLabelInputs(prev => ({ ...prev, [node.id]: { key: '', value: '' } }))
945+
}
893946
}}>Add</button>
894947
</div>
895948
</div>
@@ -932,15 +985,28 @@ export default function Nodes() {
932985
<table className="table">
933986
<thead><tr>
934987
<th>Model</th>
988+
<th>Mode</th>
935989
<th>Node Selector</th>
936990
<th>Min Replicas</th>
937991
<th>Max Replicas</th>
938992
<th style={{ textAlign: 'right' }}>Actions</th>
939993
</tr></thead>
940994
<tbody>
941-
{schedulingConfigs.map(cfg => (
995+
{schedulingConfigs.map(cfg => {
996+
const isAutoScaling = cfg.min_replicas > 0 || cfg.max_replicas > 0
997+
const hasSelector = !!cfg.node_selector
998+
const modeLabel = isAutoScaling ? 'Auto-scaling' : hasSelector ? 'Placement' : 'Inactive'
999+
const modeColor = isAutoScaling ? 'var(--color-success)' : hasSelector ? 'var(--color-primary)' : 'var(--color-text-muted)'
1000+
return (
9421001
<tr key={cfg.id || cfg.model_name}>
9431002
<td style={{ fontWeight: 600, fontSize: '0.875rem' }}>{cfg.model_name}</td>
1003+
<td>
1004+
<span style={{
1005+
display: 'inline-block', fontSize: '0.75rem', padding: '2px 8px', borderRadius: 3,
1006+
background: 'var(--color-bg-tertiary)', border: `1px solid ${modeColor}`,
1007+
color: modeColor, fontWeight: 600,
1008+
}}>{modeLabel}</span>
1009+
</td>
9441010
<td>
9451011
{cfg.node_selector ? (() => {
9461012
try {
@@ -955,8 +1021,12 @@ export default function Nodes() {
9551021
} catch { return <span style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>{cfg.node_selector}</span> }
9561022
})() : <span style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>Any node</span>}
9571023
</td>
958-
<td style={{ fontFamily: "'JetBrains Mono', monospace" }}>{cfg.min_replicas || '-'}</td>
959-
<td style={{ fontFamily: "'JetBrains Mono', monospace" }}>{cfg.max_replicas || 'unlimited'}</td>
1024+
<td style={{ fontFamily: "'JetBrains Mono', monospace" }}>
1025+
{isAutoScaling ? cfg.min_replicas : '-'}
1026+
</td>
1027+
<td style={{ fontFamily: "'JetBrains Mono', monospace" }}>
1028+
{isAutoScaling ? (cfg.max_replicas || 'no limit') : '-'}
1029+
</td>
9601030
<td style={{ textAlign: 'right' }}>
9611031
<button className="btn btn-danger btn-sm" onClick={async () => {
9621032
try {
@@ -969,7 +1039,8 @@ export default function Nodes() {
9691039
}}><i className="fas fa-trash" /></button>
9701040
</td>
9711041
</tr>
972-
))}
1042+
)
1043+
})}
9731044
</tbody>
9741045
</table>
9751046
</div>

core/services/nodes/registry.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -802,7 +802,7 @@ func (r *NodeRegistry) GetModelScheduling(ctx context.Context, modelName string)
802802
// ListModelSchedulings returns all scheduling configs.
803803
func (r *NodeRegistry) ListModelSchedulings(ctx context.Context) ([]ModelSchedulingConfig, error) {
804804
var configs []ModelSchedulingConfig
805-
err := r.db.WithContext(ctx).Find(&configs).Error
805+
err := r.db.WithContext(ctx).Order("model_name ASC").Find(&configs).Error
806806
return configs, err
807807
}
808808

@@ -831,7 +831,7 @@ func (r *NodeRegistry) CountLoadedReplicas(ctx context.Context, modelName string
831831
func (r *NodeRegistry) ListWithExtras(ctx context.Context) ([]NodeWithExtras, error) {
832832
// Get all nodes
833833
var nodes []BackendNode
834-
if err := r.db.WithContext(ctx).Find(&nodes).Error; err != nil {
834+
if err := r.db.WithContext(ctx).Order("name ASC").Find(&nodes).Error; err != nil {
835835
return nil, err
836836
}
837837

0 commit comments

Comments
 (0)