free-coding-models 0.5.10 β†’ 0.5.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * @file web/src/components/router/RouterView.jsx
3
- * @description Router Dashboard modal β€” daemon status, start/stop, model health,
4
- * request log, set management, probe mode, quick-setup card.
5
- * πŸ“– M4: Full TUI parity for the router dashboard overlay.
3
+ * @description Router Dashboard modal β€” daemon status, start/stop, active set
4
+ * manager (add / remove / drag-and-drop), probe mode, quick-setup card.
5
+ * πŸ“– M5: full set-management UI replacing the M4 read-only "Model Health" section.
6
6
  */
7
- import { useEffect, useState, useCallback } from 'react'
7
+ import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
8
8
  import {
9
9
  IconRoute, IconPlayerPlay, IconPlayerStop, IconRefresh,
10
10
  IconCopy, IconCheck, IconChevronDown, IconChevronUp,
11
- IconActivity, IconServer,
11
+ IconActivity, IconServer, IconPlus, IconX, IconGripVertical,
12
+ IconArrowUp, IconArrowDown, IconTrash, IconList, IconWand,
12
13
  } from '@tabler/icons-react'
13
14
  import styles from './RouterView.module.css'
14
15
 
@@ -38,6 +39,11 @@ function CircuitBadge({ state }) {
38
39
  return <span className={`${styles.circuitBadge} ${cls}`}>{state?.replace('_', ' ') || '?'}</span>
39
40
  }
40
41
 
42
+ const SAVE_STATUS_IDLE = { kind: 'idle' }
43
+ const SAVE_STATUS_SAVING = { kind: 'saving' }
44
+ const SAVE_STATUS_SAVED = { kind: 'saved' }
45
+ const SAVE_STATUS_ERROR = (message) => ({ kind: 'error', message })
46
+
41
47
  export default function RouterView({ onClose, onToast }) {
42
48
  const [status, setStatus] = useState(null)
43
49
  const [stats, setStats] = useState(null)
@@ -45,6 +51,18 @@ export default function RouterView({ onClose, onToast }) {
45
51
  const [actionLoading, setActionLoading] = useState(false)
46
52
  const [logExpanded, setLogExpanded] = useState(false)
47
53
  const [copied, setCopied] = useState(null)
54
+ const [autoHealDismissed, setAutoHealDismissed] = useState(false)
55
+
56
+ // πŸ“– Set management state β€” the active set, its model list (mutated
57
+ // πŸ“– locally on every drag/remove/add), and the catalog of available
58
+ // πŸ“– routeable models for the Add picker.
59
+ const [setsData, setSetsData] = useState({ activeSet: null, sets: {} })
60
+ const [catalog, setCatalog] = useState([]) // [{ key, provider, model, label, tier, ctx, hasKey }]
61
+ const [pickerOpen, setPickerOpen] = useState(false)
62
+ const [pickerSearch, setPickerSearch] = useState('')
63
+ const [pickerProvider, setPickerProvider] = useState('')
64
+ const [saveStatus, setSaveStatus] = useState(SAVE_STATUS_IDLE)
65
+ const saveTimerRef = useRef(null)
48
66
 
49
67
  const fetchStatus = useCallback(async () => {
50
68
  try {
@@ -59,12 +77,42 @@ export default function RouterView({ onClose, onToast }) {
59
77
  } catch {}
60
78
  }, [])
61
79
 
80
+ const fetchSets = useCallback(async () => {
81
+ try {
82
+ const resp = await fetch('/api/router/sets')
83
+ const data = await resp.json()
84
+ if (data && data.sets) setSetsData(data)
85
+ } catch {}
86
+ }, [])
87
+
88
+ const fetchCatalog = useCallback(async () => {
89
+ try {
90
+ const resp = await fetch('/api/router/catalog')
91
+ const data = await resp.json()
92
+ if (Array.isArray(data?.models)) setCatalog(data.models)
93
+ } catch {}
94
+ }, [])
95
+
62
96
  useEffect(() => {
63
97
  void fetchStatus()
98
+ void fetchSets()
99
+ void fetchCatalog()
64
100
  void fetch('/api/router/quick-setup').then(r => r.json()).then(setQuickSetup).catch(() => {})
65
- const interval = setInterval(fetchStatus, 5000)
101
+ const interval = setInterval(() => {
102
+ void fetchStatus()
103
+ void fetchSets()
104
+ }, 5000)
66
105
  return () => clearInterval(interval)
67
- }, [fetchStatus])
106
+ }, [fetchStatus, fetchSets, fetchCatalog])
107
+
108
+ // πŸ“– Cleanup the "saved" indicator so it fades back to idle after 1.5s.
109
+ useEffect(() => {
110
+ if (saveStatus.kind !== 'saved') return undefined
111
+ saveTimerRef.current = setTimeout(() => setSaveStatus(SAVE_STATUS_IDLE), 1500)
112
+ return () => {
113
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
114
+ }
115
+ }, [saveStatus])
68
116
 
69
117
  const handleStart = async () => {
70
118
  setActionLoading(true)
@@ -74,6 +122,7 @@ export default function RouterView({ onClose, onToast }) {
74
122
  if (data.ok || data.alreadyRunning) {
75
123
  onToast?.('Router daemon started.', 'success')
76
124
  await fetchStatus()
125
+ await fetchSets()
77
126
  } else {
78
127
  onToast?.(`Failed to start: ${data.error || 'unknown'}`, 'error')
79
128
  }
@@ -118,10 +167,250 @@ export default function RouterView({ onClose, onToast }) {
118
167
  } catch {}
119
168
  }
120
169
 
170
+ // ── Set management helpers ────────────────────────────────────────────
171
+ const activeSetName = setsData?.activeSet || status?.activeSet || 'fast-coding'
172
+ const activeSet = setsData?.sets?.[activeSetName] || { models: [] }
173
+ const models = Array.isArray(activeSet.models) ? activeSet.models : []
174
+
175
+ const setActiveSet = async (name) => {
176
+ try {
177
+ await fetch(`/api/router/sets/${encodeURIComponent(name)}/activate`, { method: 'POST' })
178
+ onToast?.(`Active set: ${name}`, 'info')
179
+ await fetchSets()
180
+ await fetchStatus()
181
+ } catch (err) {
182
+ onToast?.(`Failed to activate: ${err.message}`, 'error')
183
+ }
184
+ }
185
+
186
+ // πŸ“– "Sync best" β€” re-run the probe pipeline against the user's actual
187
+ // πŸ“– API keys and rebuild the set with only models that come back 2xx.
188
+ // πŸ“– This is the one-click "default to working models" path for users
189
+ // πŸ“– whose keys have changed since the last sync or who want a fresh
190
+ // πŸ“– probe-driven ranking. The daemon shows probe progress to the UI
191
+ // πŸ“– and returns the new model list.
192
+ const handleSyncBest = async () => {
193
+ if (!activeSetName) return
194
+ setSaveStatus(SAVE_STATUS_SAVING)
195
+ onToast?.('Probing models with your keys…', 'info')
196
+ try {
197
+ const resp = await fetch(`/api/router/sets/${encodeURIComponent(activeSetName)}/sync`, { method: 'POST' })
198
+ if (!resp.ok) {
199
+ const err = await resp.json().catch(() => ({}))
200
+ throw new Error(err.error || `HTTP ${resp.status}`)
201
+ }
202
+ const data = await resp.json()
203
+ const picked = data.selected?.length || 0
204
+ const probed = data.probeCount || 0
205
+ onToast?.(`Synced ${activeSetName}: ${picked} working model${picked === 1 ? '' : 's'} from ${probed} probes.`, 'success')
206
+ await fetchSets()
207
+ await fetchStatus()
208
+ setSaveStatus(SAVE_STATUS_SAVED)
209
+ } catch (err) {
210
+ setSaveStatus(SAVE_STATUS_ERROR(err.message || String(err)))
211
+ onToast?.(`Sync failed: ${err.message}`, 'error')
212
+ }
213
+ }
214
+
215
+ const persistReorder = useCallback(async (nextModels) => {
216
+ if (!activeSetName) return
217
+ setSaveStatus(SAVE_STATUS_SAVING)
218
+ try {
219
+ const order = nextModels.map((m) => `${m.provider}/${m.model}`)
220
+ const resp = await fetch(`/api/router/sets/${encodeURIComponent(activeSetName)}/reorder`, {
221
+ method: 'POST',
222
+ headers: { 'Content-Type': 'application/json' },
223
+ body: JSON.stringify({ order }),
224
+ })
225
+ if (!resp.ok) {
226
+ const err = await resp.json().catch(() => ({}))
227
+ throw new Error(err.error || `HTTP ${resp.status}`)
228
+ }
229
+ const data = await resp.json()
230
+ if (data?.sets?.[activeSetName]) {
231
+ setSetsData((prev) => ({ ...prev, sets: data.sets }))
232
+ } else {
233
+ await fetchSets()
234
+ }
235
+ setSaveStatus(SAVE_STATUS_SAVED)
236
+ } catch (err) {
237
+ setSaveStatus(SAVE_STATUS_ERROR(err.message || String(err)))
238
+ onToast?.(`Reorder failed: ${err.message}`, 'error')
239
+ }
240
+ }, [activeSetName, fetchSets, onToast])
241
+
242
+ const persistAdd = useCallback(async (provider, model) => {
243
+ if (!activeSetName) return
244
+ setSaveStatus(SAVE_STATUS_SAVING)
245
+ try {
246
+ const resp = await fetch(`/api/router/sets/${encodeURIComponent(activeSetName)}/models`, {
247
+ method: 'POST',
248
+ headers: { 'Content-Type': 'application/json' },
249
+ body: JSON.stringify({ provider, model }),
250
+ })
251
+ if (!resp.ok) {
252
+ const err = await resp.json().catch(() => ({}))
253
+ throw new Error(err.error || `HTTP ${resp.status}`)
254
+ }
255
+ const data = await resp.json()
256
+ if (data?.sets?.[activeSetName]) {
257
+ setSetsData((prev) => ({ ...prev, sets: data.sets }))
258
+ } else {
259
+ await fetchSets()
260
+ }
261
+ setSaveStatus(SAVE_STATUS_SAVED)
262
+ onToast?.(`Added ${provider}/${model} to ${activeSetName}.`, 'success')
263
+ } catch (err) {
264
+ setSaveStatus(SAVE_STATUS_ERROR(err.message || String(err)))
265
+ onToast?.(`Add failed: ${err.message}`, 'error')
266
+ }
267
+ }, [activeSetName, fetchSets, onToast])
268
+
269
+ const persistRemove = useCallback(async (provider, model) => {
270
+ if (!activeSetName) return
271
+ setSaveStatus(SAVE_STATUS_SAVING)
272
+ try {
273
+ const resp = await fetch(`/api/router/sets/${encodeURIComponent(activeSetName)}/models`, {
274
+ method: 'DELETE',
275
+ headers: { 'Content-Type': 'application/json' },
276
+ body: JSON.stringify({ provider, model }),
277
+ })
278
+ if (!resp.ok) {
279
+ const err = await resp.json().catch(() => ({}))
280
+ throw new Error(err.error || `HTTP ${resp.status}`)
281
+ }
282
+ const data = await resp.json()
283
+ if (data?.sets?.[activeSetName]) {
284
+ setSetsData((prev) => ({ ...prev, sets: data.sets }))
285
+ } else {
286
+ await fetchSets()
287
+ }
288
+ setSaveStatus(SAVE_STATUS_SAVED)
289
+ } catch (err) {
290
+ setSaveStatus(SAVE_STATUS_ERROR(err.message || String(err)))
291
+ onToast?.(`Remove failed: ${err.message}`, 'error')
292
+ }
293
+ }, [activeSetName, fetchSets, onToast])
294
+
295
+ // ── Drag and drop state ───────────────────────────────────────────────
296
+ // We keep a local copy of `models` so the drag UX is instant β€” the
297
+ // server is updated only when the user actually drops the row.
298
+ const [localModels, setLocalModels] = useState(models)
299
+ useEffect(() => { setLocalModels(models) }, [models])
300
+ const [draggingKey, setDraggingKey] = useState(null)
301
+ const [dropPosition, setDropPosition] = useState(null) // { key, side: 'above' | 'below' } | null
302
+
303
+ const handleMove = useCallback(async (idx, direction) => {
304
+ const next = [...localModels]
305
+ const newIdx = direction === 'up' ? idx - 1 : idx + 1
306
+ if (newIdx < 0 || newIdx >= next.length) return
307
+ const [moved] = next.splice(idx, 1)
308
+ next.splice(newIdx, 0, moved)
309
+ setLocalModels(next)
310
+ await persistReorder(next)
311
+ }, [localModels, persistReorder])
312
+
313
+ const handleRemove = useCallback(async (idx) => {
314
+ const target = localModels[idx]
315
+ if (!target) return
316
+ // Optimistic update: drop the row immediately, send the DELETE after.
317
+ const next = localModels.filter((_, i) => i !== idx)
318
+ setLocalModels(next)
319
+ await persistRemove(target.provider, target.model)
320
+ }, [localModels, persistRemove])
321
+
322
+ const handleDragStart = (e, idx) => {
323
+ const target = localModels[idx]
324
+ if (!target) return
325
+ setDraggingKey(`${target.provider}/${target.model}`)
326
+ // πŸ“– dataTransfer is required for Firefox to actually fire drag events.
327
+ e.dataTransfer.effectAllowed = 'move'
328
+ e.dataTransfer.setData('text/plain', `${target.provider}/${target.model}`)
329
+ }
330
+
331
+ const handleDragOver = (e, idx) => {
332
+ if (draggingKey == null) return
333
+ e.preventDefault()
334
+ e.dataTransfer.dropEffect = 'move'
335
+ const target = localModels[idx]
336
+ if (!target) return
337
+ const key = `${target.provider}/${target.model}`
338
+ if (key === draggingKey) return
339
+ const rect = e.currentTarget.getBoundingClientRect()
340
+ const side = e.clientY < rect.top + rect.height / 2 ? 'above' : 'below'
341
+ setDropPosition({ key, side })
342
+ }
343
+
344
+ const handleDragLeave = (e) => {
345
+ // πŸ“– Don't clear on every leave β€” only when we leave the list entirely.
346
+ if (e.currentTarget.contains(e.relatedTarget)) return
347
+ }
348
+
349
+ const handleDrop = async (e, idx) => {
350
+ e.preventDefault()
351
+ if (draggingKey == null) return
352
+ const dragIdx = localModels.findIndex((m) => `${m.provider}/${m.model}` === draggingKey)
353
+ if (dragIdx < 0) {
354
+ setDraggingKey(null)
355
+ setDropPosition(null)
356
+ return
357
+ }
358
+ const target = localModels[idx]
359
+ if (!target) {
360
+ setDraggingKey(null)
361
+ setDropPosition(null)
362
+ return
363
+ }
364
+ const rect = e.currentTarget.getBoundingClientRect()
365
+ const side = e.clientY < rect.top + rect.height / 2 ? 'above' : 'below'
366
+ let insertAt = side === 'above' ? idx : idx + 1
367
+ if (dragIdx < insertAt) insertAt -= 1
368
+ if (insertAt === dragIdx) {
369
+ setDraggingKey(null)
370
+ setDropPosition(null)
371
+ return
372
+ }
373
+ const next = [...localModels]
374
+ const [moved] = next.splice(dragIdx, 1)
375
+ next.splice(insertAt, 0, moved)
376
+ setLocalModels(next)
377
+ setDraggingKey(null)
378
+ setDropPosition(null)
379
+ await persistReorder(next)
380
+ }
381
+
382
+ const handleDragEnd = () => {
383
+ setDraggingKey(null)
384
+ setDropPosition(null)
385
+ }
386
+
387
+ // ── Picker filter ────────────────────────────────────────────────────
388
+ const providers = useMemo(() => {
389
+ const set = new Set(catalog.map((m) => m.provider))
390
+ return Array.from(set).sort()
391
+ }, [catalog])
392
+
393
+ const filteredCatalog = useMemo(() => {
394
+ const q = pickerSearch.trim().toLowerCase()
395
+ return catalog.filter((m) => {
396
+ if (pickerProvider && m.provider !== pickerProvider) return false
397
+ if (!q) return true
398
+ return (
399
+ m.key.toLowerCase().includes(q)
400
+ || (m.label || '').toLowerCase().includes(q)
401
+ || m.provider.toLowerCase().includes(q)
402
+ )
403
+ }).slice(0, 200)
404
+ }, [catalog, pickerSearch, pickerProvider])
405
+
406
+ const modelKeyInSet = (provider, model) => localModels.some((m) => m.provider === provider && m.model === model)
407
+
121
408
  const running = status?.ok
122
- const models = stats?.models || []
123
- const requestLog = stats?.requestLog || []
124
409
  const circuitBreakers = stats?.circuitBreakers || {}
410
+ const requestLog = stats?.requestLog || []
411
+
412
+ const sets = setsData?.sets || {}
413
+ const setNames = Object.keys(sets).sort()
125
414
 
126
415
  return (
127
416
  <div className={styles.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
@@ -135,6 +424,37 @@ export default function RouterView({ onClose, onToast }) {
135
424
  </div>
136
425
 
137
426
  <div className={styles.body}>
427
+ {/* Auto-heal banner β€” shown when the daemon detected broken
428
+ models in the active set on startup. The banner disappears
429
+ once the user clicks "Sync best" or "Fix now" (which heals
430
+ the set and reloads the page state). */}
431
+ {running && status?.brokenModelCount > 0 && !autoHealDismissed && (
432
+ <div className={styles.autoHealBanner}>
433
+ <div className={styles.autoHealLeft}>
434
+ <span className={styles.autoHealIcon}>⚠</span>
435
+ <div>
436
+ <div className={styles.autoHealTitle}>
437
+ {status.brokenModelCount} model{status.brokenModelCount === 1 ? '' : 's'} in the active set are not responding
438
+ </div>
439
+ <div className={styles.autoHealHint}>
440
+ Auto-heal ran on startup but the replacement may also be broken.
441
+ Click <strong>Sync best</strong> below to re-probe with your current keys,
442
+ or click <strong>Fix now</strong> to manually replace the broken entries.
443
+ </div>
444
+ </div>
445
+ </div>
446
+ <div className={styles.autoHealActions}>
447
+ <button className={styles.smallBtn} onClick={handleSyncBest}>
448
+ <IconWand size={11} />
449
+ Fix now
450
+ </button>
451
+ <button className={styles.iconBtn} onClick={() => setAutoHealDismissed(true)} aria-label="Dismiss">
452
+ <IconX size={12} />
453
+ </button>
454
+ </div>
455
+ </div>
456
+ )}
457
+
138
458
  {/* Hero Card */}
139
459
  <div className={`${styles.heroCard} ${running ? styles.heroRunning : styles.heroStopped}`}>
140
460
  <div className={styles.heroLeft}>
@@ -203,6 +523,173 @@ export default function RouterView({ onClose, onToast }) {
203
523
  </div>
204
524
  )}
205
525
 
526
+ {/* Active Set Manager */}
527
+ {running && (
528
+ <div className={styles.section}>
529
+ <h3 className={styles.sectionTitle}>
530
+ <IconList size={14} />
531
+ Active Set ({localModels.length} models)
532
+ </h3>
533
+
534
+ <div className={styles.setMeta}>
535
+ <div className={styles.setActions}>
536
+ <span className={styles.setMetaName}>{activeSetName}</span>
537
+ {setNames.length > 1 && (
538
+ <select
539
+ className={styles.pickerSelect}
540
+ value={activeSetName}
541
+ onChange={(e) => setActiveSet(e.target.value)}
542
+ title="Switch the active set"
543
+ >
544
+ {setNames.map((n) => (
545
+ <option key={n} value={n}>{n}</option>
546
+ ))}
547
+ </select>
548
+ )}
549
+ </div>
550
+ <div className={styles.setActions}>
551
+ <SaveBadge status={saveStatus} />
552
+ <button
553
+ className={styles.smallBtn}
554
+ onClick={handleSyncBest}
555
+ disabled={saveStatus.kind === 'saving'}
556
+ title="Probe your API keys and rebuild the set with only models that actually work"
557
+ >
558
+ <IconWand size={11} />
559
+ Sync best
560
+ </button>
561
+ <button
562
+ className={styles.primaryBtn}
563
+ onClick={() => setPickerOpen((v) => !v)}
564
+ disabled={saveStatus.kind === 'saving'}
565
+ >
566
+ {pickerOpen ? <IconX size={11} /> : <IconPlus size={11} />}
567
+ {pickerOpen ? 'Close' : 'Add model'}
568
+ </button>
569
+ </div>
570
+ </div>
571
+
572
+ {localModels.length === 0 ? (
573
+ <div className={styles.setEmpty}>
574
+ The active set is empty. Add models with the button above to start routing.
575
+ </div>
576
+ ) : (
577
+ <div className={styles.setList} onDragLeave={handleDragLeave}>
578
+ {localModels.map((m, idx) => {
579
+ const key = `${m.provider}/${m.model}`
580
+ const cb = circuitBreakers[key] || {}
581
+ const isDragging = draggingKey === key
582
+ const dropAbove = dropPosition?.key === key && dropPosition.side === 'above'
583
+ const dropBelow = dropPosition?.key === key && dropPosition.side === 'below'
584
+ return (
585
+ <div
586
+ key={key}
587
+ className={`${styles.setRow} ${isDragging ? styles.setRowDragging : ''} ${dropAbove ? `${styles.setRowDropTarget} ${styles.setRowDropTargetAbove}` : ''} ${dropBelow ? `${styles.setRowDropTarget} ${styles.setRowDropTargetBelow}` : ''}`}
588
+ draggable
589
+ onDragStart={(e) => handleDragStart(e, idx)}
590
+ onDragOver={(e) => handleDragOver(e, idx)}
591
+ onDrop={(e) => handleDrop(e, idx)}
592
+ onDragEnd={handleDragEnd}
593
+ title={key}
594
+ >
595
+ <span className={styles.setDragHandle} aria-hidden>
596
+ <IconGripVertical size={14} />
597
+ </span>
598
+ <span className={styles.setPriority}>#{idx + 1}</span>
599
+ <span className={styles.setKey}>{key}</span>
600
+ {m.tier && <span className={styles.setTier}>{m.tier}</span>}
601
+ <CircuitBadge state={cb.state || m.state} />
602
+ <div className={styles.setRowBtns}>
603
+ <button
604
+ className={styles.iconBtn}
605
+ onClick={() => handleMove(idx, 'up')}
606
+ disabled={idx === 0 || saveStatus.kind === 'saving'}
607
+ title="Move up"
608
+ aria-label={`Move ${key} up`}
609
+ >
610
+ <IconArrowUp size={12} />
611
+ </button>
612
+ <button
613
+ className={styles.iconBtn}
614
+ onClick={() => handleMove(idx, 'down')}
615
+ disabled={idx === localModels.length - 1 || saveStatus.kind === 'saving'}
616
+ title="Move down"
617
+ aria-label={`Move ${key} down`}
618
+ >
619
+ <IconArrowDown size={12} />
620
+ </button>
621
+ <button
622
+ className={`${styles.iconBtn} ${styles.removeBtn}`}
623
+ onClick={() => handleRemove(idx)}
624
+ disabled={saveStatus.kind === 'saving'}
625
+ title="Remove from set"
626
+ aria-label={`Remove ${key}`}
627
+ >
628
+ <IconTrash size={12} />
629
+ </button>
630
+ </div>
631
+ </div>
632
+ )
633
+ })}
634
+ </div>
635
+ )}
636
+
637
+ {pickerOpen && (
638
+ <div className={styles.pickerPanel}>
639
+ <div className={styles.pickerHeader}>
640
+ <span>Add a model to <code>{activeSetName}</code></span>
641
+ <span style={{ color: 'var(--text-muted, #888)' }}>
642
+ {filteredCatalog.length} of {catalog.length}
643
+ </span>
644
+ </div>
645
+ <div className={styles.pickerSearch}>
646
+ <input
647
+ className={styles.pickerInput}
648
+ placeholder="Search by provider, model, or label…"
649
+ value={pickerSearch}
650
+ onChange={(e) => setPickerSearch(e.target.value)}
651
+ autoFocus
652
+ />
653
+ <select
654
+ className={styles.pickerSelect}
655
+ value={pickerProvider}
656
+ onChange={(e) => setPickerProvider(e.target.value)}
657
+ >
658
+ <option value="">All providers</option>
659
+ {providers.map((p) => (
660
+ <option key={p} value={p}>{p}</option>
661
+ ))}
662
+ </select>
663
+ </div>
664
+ <div className={styles.pickerList}>
665
+ {filteredCatalog.length === 0 ? (
666
+ <div className={styles.pickerEmpty}>No models match your filter.</div>
667
+ ) : (
668
+ filteredCatalog.map((entry) => {
669
+ const inSet = modelKeyInSet(entry.provider, entry.model)
670
+ return (
671
+ <div
672
+ key={entry.key}
673
+ className={`${styles.pickerItem} ${inSet ? styles.pickerItemAdded : ''}`}
674
+ onClick={() => { if (!inSet) void persistAdd(entry.provider, entry.model) }}
675
+ title={inSet ? 'Already in set' : `Add ${entry.key}`}
676
+ >
677
+ <span className={styles.pickerProvider}>{entry.provider}</span>
678
+ <span className={styles.pickerModel}>{entry.label || entry.model}</span>
679
+ {entry.tier && <span className={styles.setTier}>{entry.tier}</span>}
680
+ {entry.hasKey
681
+ ? <span className={`${styles.pickerBadge} ${styles.pickerBadgeOk}`}>key</span>
682
+ : <span className={styles.pickerBadge}>no key</span>}
683
+ </div>
684
+ )
685
+ })
686
+ )}
687
+ </div>
688
+ </div>
689
+ )}
690
+ </div>
691
+ )}
692
+
206
693
  {/* Probe Mode */}
207
694
  {running && (
208
695
  <div className={styles.section}>
@@ -224,32 +711,6 @@ export default function RouterView({ onClose, onToast }) {
224
711
  </div>
225
712
  )}
226
713
 
227
- {/* Model Health */}
228
- {running && models.length > 0 && (
229
- <div className={styles.section}>
230
- <h3 className={styles.sectionTitle}>
231
- <IconServer size={14} />
232
- Model Health ({models.length})
233
- </h3>
234
- <div className={styles.modelList}>
235
- {models.map((m) => {
236
- const cb = circuitBreakers[m.key] || {}
237
- return (
238
- <div key={m.key} className={styles.modelRow}>
239
- <span className={styles.modelPriority}>#{m.priority}</span>
240
- <span className={styles.modelName} title={m.key}>{m.key}</span>
241
- <CircuitBadge state={cb.state || m.state} />
242
- <span className={styles.modelScore}>{m.score?.toFixed(2) || 'β€”'}</span>
243
- <span className={styles.modelLatency}>
244
- {m.last_latency_ms != null ? `${m.last_latency_ms}ms` : 'β€”'}
245
- </span>
246
- </div>
247
- )
248
- })}
249
- </div>
250
- </div>
251
- )}
252
-
253
714
  {/* Request Log */}
254
715
  {running && requestLog.length > 0 && (
255
716
  <div className={styles.section}>
@@ -279,8 +740,31 @@ export default function RouterView({ onClose, onToast }) {
279
740
  )}
280
741
  </div>
281
742
  )}
743
+
744
+ {/* Server health (small chip at the bottom for visibility) */}
745
+ <div className={styles.section} style={{ marginBottom: 0, marginTop: 16 }}>
746
+ <span className={styles.saveStatus}>
747
+ {stats?.tokenStats ? `${formatNumber(stats.tokenStats.all_time?.total_tokens || 0)} tokens routed lifetime` : ''}
748
+ </span>
749
+ </div>
282
750
  </div>
283
751
  </div>
284
752
  </div>
285
753
  )
286
754
  }
755
+
756
+ function SaveBadge({ status }) {
757
+ if (!status || status.kind === 'idle') return null
758
+ if (status.kind === 'saving') {
759
+ return <span className={styles.saveStatus}>saving…</span>
760
+ }
761
+ if (status.kind === 'saved') {
762
+ return <span className={`${styles.saveStatus} ${styles.saveStatusOk}`}>βœ“ saved</span>
763
+ }
764
+ if (status.kind === 'error') {
765
+ return <span className={`${styles.saveStatus} ${styles.saveStatusErr}`} title={status.message}>
766
+ ⚠ {status.message?.slice(0, 40) || 'error'}
767
+ </span>
768
+ }
769
+ return null
770
+ }