11import { useState , useEffect , useCallback , Fragment } from 'react'
22import { useOutletContext , useNavigate } from 'react-router-dom'
33import { nodesApi } from '../utils/api'
4+ import { useModels } from '../hooks/useModels'
45import LoadingSpinner from '../components/LoadingSpinner'
56import ConfirmDialog from '../components/ConfirmDialog'
67import ImageSelector , { useImageSelector , dockerImage , dockerFlags } from '../components/ImageSelector'
@@ -37,6 +38,7 @@ function gpuVendorLabel(vendor) {
3738const 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
162164function 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 >
0 commit comments