free-coding-models 0.5.6 → 0.5.8

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.
Files changed (48) hide show
  1. package/README.md +14 -9
  2. package/changelog/v0.5.7.md +20 -0
  3. package/package.json +1 -1
  4. package/src/core/endpoint-installer.js +3 -3
  5. package/src/core/tool-launchers.js +2 -2
  6. package/web/dist/assets/index-DKmmiDip.css +1 -0
  7. package/web/dist/assets/index-DzVt42Nf.js +39 -0
  8. package/web/dist/index.html +2 -2
  9. package/web/server.js +386 -1
  10. package/web/src/App.jsx +145 -26
  11. package/web/src/components/analytics/AnalyticsView.jsx +4 -0
  12. package/web/src/components/analytics/TokenUsagePanel.jsx +105 -0
  13. package/web/src/components/analytics/TokenUsagePanel.module.css +126 -0
  14. package/web/src/components/atoms/TierBadge.module.css +3 -3
  15. package/web/src/components/dashboard/DetailPanel.jsx +29 -4
  16. package/web/src/components/dashboard/DetailPanel.module.css +26 -0
  17. package/web/src/components/dashboard/FilterBar.jsx +17 -2
  18. package/web/src/components/dashboard/FilterBar.module.css +17 -0
  19. package/web/src/components/dashboard/ModelTable.jsx +17 -5
  20. package/web/src/components/dashboard/ModelTable.module.css +14 -1
  21. package/web/src/components/help/HelpView.jsx +1 -1
  22. package/web/src/components/install/InstallEndpointsView.jsx +278 -0
  23. package/web/src/components/install/InstallEndpointsView.module.css +351 -0
  24. package/web/src/components/installed/InstalledModelsView.jsx +89 -0
  25. package/web/src/components/installed/InstalledModelsView.module.css +200 -0
  26. package/web/src/components/launch/IncompatibleFallbackModal.jsx +69 -0
  27. package/web/src/components/launch/LaunchButton.jsx +30 -0
  28. package/web/src/components/launch/LaunchButton.module.css +35 -0
  29. package/web/src/components/launch/LaunchModal.module.css +125 -0
  30. package/web/src/components/layout/Header.jsx +70 -11
  31. package/web/src/components/layout/Header.module.css +62 -2
  32. package/web/src/components/recommend/RecommendView.jsx +121 -0
  33. package/web/src/components/recommend/RecommendView.module.css +55 -0
  34. package/web/src/components/router/RouterView.jsx +286 -0
  35. package/web/src/components/router/RouterView.module.css +357 -0
  36. package/web/src/components/tools/ToolPicker.jsx +86 -0
  37. package/web/src/components/tools/ToolPicker.module.css +84 -0
  38. package/web/src/global.css +23 -0
  39. package/web/src/hooks/urlState.constants.js +3 -0
  40. package/web/src/hooks/useInstalledModels.js +42 -0
  41. package/web/src/hooks/useRecommend.js +67 -0
  42. package/web/src/hooks/useRouterDashboard.js +133 -0
  43. package/web/src/hooks/useTokenUsage.js +103 -0
  44. package/web/src/hooks/useToolMode.js +77 -0
  45. package/web/src/hooks/useUrlState.js +4 -3
  46. package/web/src/utils/m3.js +55 -0
  47. package/web/dist/assets/index-Blp9QJev.js +0 -39
  48. package/web/dist/assets/index-Cz_aCLTR.css +0 -1
@@ -0,0 +1,286 @@
1
+ /**
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.
6
+ */
7
+ import { useEffect, useState, useCallback } from 'react'
8
+ import {
9
+ IconRoute, IconPlayerPlay, IconPlayerStop, IconRefresh,
10
+ IconCopy, IconCheck, IconChevronDown, IconChevronUp,
11
+ IconActivity, IconServer,
12
+ } from '@tabler/icons-react'
13
+ import styles from './RouterView.module.css'
14
+
15
+ function formatUptime(seconds) {
16
+ if (!seconds || seconds <= 0) return '—'
17
+ const h = Math.floor(seconds / 3600)
18
+ const m = Math.floor((seconds % 3600) / 60)
19
+ const s = seconds % 60
20
+ if (h > 0) return `${h}h ${m}m`
21
+ if (m > 0) return `${m}m ${s}s`
22
+ return `${s}s`
23
+ }
24
+
25
+ function formatNumber(n) {
26
+ if (typeof n !== 'number' || !Number.isFinite(n)) return '0'
27
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
28
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
29
+ return String(n)
30
+ }
31
+
32
+ function CircuitBadge({ state }) {
33
+ const cls = state === 'CLOSED' ? styles.circuitClosed
34
+ : state === 'OPEN' ? styles.circuitOpen
35
+ : state === 'HALF_OPEN' ? styles.circuitHalfOpen
36
+ : state === 'AUTH_ERROR' ? styles.circuitAuth
37
+ : styles.circuitUnknown
38
+ return <span className={`${styles.circuitBadge} ${cls}`}>{state?.replace('_', ' ') || '?'}</span>
39
+ }
40
+
41
+ export default function RouterView({ onClose, onToast }) {
42
+ const [status, setStatus] = useState(null)
43
+ const [stats, setStats] = useState(null)
44
+ const [quickSetup, setQuickSetup] = useState(null)
45
+ const [actionLoading, setActionLoading] = useState(false)
46
+ const [logExpanded, setLogExpanded] = useState(false)
47
+ const [copied, setCopied] = useState(null)
48
+
49
+ const fetchStatus = useCallback(async () => {
50
+ try {
51
+ const resp = await fetch('/api/router/status')
52
+ const data = await resp.json()
53
+ setStatus(data)
54
+ if (data?.ok) {
55
+ const statsResp = await fetch('/api/router/stats')
56
+ const statsData = await statsResp.json()
57
+ if (statsData.ok) setStats(statsData)
58
+ }
59
+ } catch {}
60
+ }, [])
61
+
62
+ useEffect(() => {
63
+ void fetchStatus()
64
+ void fetch('/api/router/quick-setup').then(r => r.json()).then(setQuickSetup).catch(() => {})
65
+ const interval = setInterval(fetchStatus, 5000)
66
+ return () => clearInterval(interval)
67
+ }, [fetchStatus])
68
+
69
+ const handleStart = async () => {
70
+ setActionLoading(true)
71
+ try {
72
+ const resp = await fetch('/api/router/start', { method: 'POST' })
73
+ const data = await resp.json()
74
+ if (data.ok || data.alreadyRunning) {
75
+ onToast?.('Router daemon started.', 'success')
76
+ await fetchStatus()
77
+ } else {
78
+ onToast?.(`Failed to start: ${data.error || 'unknown'}`, 'error')
79
+ }
80
+ } catch (err) {
81
+ onToast?.(`Start failed: ${err.message}`, 'error')
82
+ } finally { setActionLoading(false) }
83
+ }
84
+
85
+ const handleStop = async () => {
86
+ setActionLoading(true)
87
+ try {
88
+ const resp = await fetch('/api/router/stop', { method: 'POST' })
89
+ const data = await resp.json()
90
+ if (data.ok) {
91
+ onToast?.('Router daemon stopped.', 'success')
92
+ setStatus({ ok: false, running: false })
93
+ setStats(null)
94
+ } else {
95
+ onToast?.(`Failed to stop: ${data.error || 'unknown'}`, 'error')
96
+ }
97
+ } catch (err) {
98
+ onToast?.(`Stop failed: ${err.message}`, 'error')
99
+ } finally { setActionLoading(false) }
100
+ }
101
+
102
+ const handleCopy = (text, label) => {
103
+ navigator.clipboard.writeText(text).then(() => {
104
+ setCopied(label)
105
+ setTimeout(() => setCopied(null), 1500)
106
+ })
107
+ }
108
+
109
+ const handleSetProbeMode = async (mode) => {
110
+ try {
111
+ await fetch('/api/router/probe-mode', {
112
+ method: 'POST',
113
+ headers: { 'Content-Type': 'application/json' },
114
+ body: JSON.stringify({ probeMode: mode }),
115
+ })
116
+ onToast?.(`Probe mode set to ${mode}.`, 'info')
117
+ await fetchStatus()
118
+ } catch {}
119
+ }
120
+
121
+ const running = status?.ok
122
+ const models = stats?.models || []
123
+ const requestLog = stats?.requestLog || []
124
+ const circuitBreakers = stats?.circuitBreakers || {}
125
+
126
+ return (
127
+ <div className={styles.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
128
+ <div className={styles.modal}>
129
+ <div className={styles.header}>
130
+ <h2 className={styles.title}>
131
+ <IconRoute size={20} stroke={1.5} />
132
+ Router Dashboard
133
+ </h2>
134
+ <button className={styles.closeBtn} onClick={onClose} aria-label="Close">✕</button>
135
+ </div>
136
+
137
+ <div className={styles.body}>
138
+ {/* Hero Card */}
139
+ <div className={`${styles.heroCard} ${running ? styles.heroRunning : styles.heroStopped}`}>
140
+ <div className={styles.heroLeft}>
141
+ <div className={styles.heroStatus}>
142
+ <span className={`${styles.statusDot} ${running ? styles.dotGreen : styles.dotGray}`} />
143
+ <span className={styles.heroLabel}>{running ? 'Running' : 'Stopped'}</span>
144
+ </div>
145
+ {running && (
146
+ <div className={styles.heroMeta}>
147
+ <span>Port {status.port}</span>
148
+ <span>·</span>
149
+ <span>Uptime {formatUptime(status.uptimeSeconds)}</span>
150
+ <span>·</span>
151
+ <span>{status.requestsRouted} requests</span>
152
+ </div>
153
+ )}
154
+ {!running && (
155
+ <div className={styles.heroMeta}>
156
+ Smart failover router — start to route requests to the healthiest model.
157
+ </div>
158
+ )}
159
+ </div>
160
+ <div className={styles.heroActions}>
161
+ {!running ? (
162
+ <button className={styles.startBtn} onClick={handleStart} disabled={actionLoading}>
163
+ <IconPlayerPlay size={14} />
164
+ {actionLoading ? 'Starting…' : 'Start Router'}
165
+ </button>
166
+ ) : (
167
+ <button className={styles.stopBtn} onClick={handleStop} disabled={actionLoading}>
168
+ <IconPlayerStop size={14} />
169
+ {actionLoading ? 'Stopping…' : 'Stop'}
170
+ </button>
171
+ )}
172
+ <button className={styles.refreshBtn} onClick={fetchStatus} title="Refresh">
173
+ <IconRefresh size={14} />
174
+ </button>
175
+ </div>
176
+ </div>
177
+
178
+ {/* Quick Setup */}
179
+ {running && quickSetup && (
180
+ <div className={styles.quickSetup}>
181
+ <h3 className={styles.sectionTitle}>
182
+ <IconCopy size={14} />
183
+ Quick Setup
184
+ </h3>
185
+ <div className={styles.quickRows}>
186
+ {quickSetup.baseUrl && (
187
+ <div className={styles.quickRow}>
188
+ <span className={styles.quickLabel}>Base URL</span>
189
+ <code className={styles.quickValue}>{quickSetup.baseUrl}</code>
190
+ <button className={styles.copyBtn} onClick={() => handleCopy(quickSetup.baseUrl, 'url')}>
191
+ {copied === 'url' ? <IconCheck size={12} /> : <IconCopy size={12} />}
192
+ </button>
193
+ </div>
194
+ )}
195
+ <div className={styles.quickRow}>
196
+ <span className={styles.quickLabel}>Model</span>
197
+ <code className={styles.quickValue}>{quickSetup.model}</code>
198
+ <button className={styles.copyBtn} onClick={() => handleCopy(quickSetup.model, 'model')}>
199
+ {copied === 'model' ? <IconCheck size={12} /> : <IconCopy size={12} />}
200
+ </button>
201
+ </div>
202
+ </div>
203
+ </div>
204
+ )}
205
+
206
+ {/* Probe Mode */}
207
+ {running && (
208
+ <div className={styles.section}>
209
+ <h3 className={styles.sectionTitle}>
210
+ <IconActivity size={14} />
211
+ Probe Mode
212
+ </h3>
213
+ <div className={styles.probeModes}>
214
+ {['eco', 'balanced', 'aggressive'].map((mode) => (
215
+ <button
216
+ key={mode}
217
+ className={`${styles.probeBtn} ${status?.probeMode === mode ? styles.probeActive : ''}`}
218
+ onClick={() => handleSetProbeMode(mode)}
219
+ >
220
+ {mode}
221
+ </button>
222
+ ))}
223
+ </div>
224
+ </div>
225
+ )}
226
+
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
+ {/* Request Log */}
254
+ {running && requestLog.length > 0 && (
255
+ <div className={styles.section}>
256
+ <h3 className={styles.sectionTitle} onClick={() => setLogExpanded(!logExpanded)} style={{ cursor: 'pointer' }}>
257
+ <IconActivity size={14} />
258
+ Request Log ({requestLog.length})
259
+ {logExpanded ? <IconChevronUp size={12} /> : <IconChevronDown size={12} />}
260
+ </h3>
261
+ {logExpanded && (
262
+ <div className={styles.logList}>
263
+ {requestLog.map((entry, i) => (
264
+ <div key={i} className={styles.logRow}>
265
+ <span className={entry.error ? styles.logErr : styles.logOk}>
266
+ {entry.status || '—'}
267
+ </span>
268
+ <span className={styles.logModel}>{entry.model}</span>
269
+ <span className={styles.logLatency}>
270
+ {entry.latency_ms != null ? `${entry.latency_ms}ms` : '—'}
271
+ </span>
272
+ <span className={styles.logTokens}>
273
+ {entry.tokens > 0 ? formatNumber(entry.tokens) + ' tok' : ''}
274
+ </span>
275
+ {entry.failover && <span className={styles.logFailover}>failover</span>}
276
+ </div>
277
+ ))}
278
+ </div>
279
+ )}
280
+ </div>
281
+ )}
282
+ </div>
283
+ </div>
284
+ </div>
285
+ )
286
+ }
@@ -0,0 +1,357 @@
1
+ .overlay {
2
+ position: fixed;
3
+ inset: 0;
4
+ z-index: 200;
5
+ background: rgba(0, 0, 0, 0.6);
6
+ display: flex;
7
+ align-items: center;
8
+ justify-content: center;
9
+ animation: fadeIn 0.15s ease;
10
+ }
11
+
12
+ .modal {
13
+ background: var(--bg-primary, #0f0f17);
14
+ border: 1px solid var(--border, #1e1e2e);
15
+ border-radius: 12px;
16
+ width: 720px;
17
+ max-width: 95vw;
18
+ max-height: 85vh;
19
+ display: flex;
20
+ flex-direction: column;
21
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
22
+ }
23
+
24
+ .header {
25
+ display: flex;
26
+ align-items: center;
27
+ justify-content: space-between;
28
+ padding: 16px 20px;
29
+ border-bottom: 1px solid var(--border, #1e1e2e);
30
+ }
31
+
32
+ .title {
33
+ margin: 0;
34
+ font-size: 16px;
35
+ font-weight: 600;
36
+ display: flex;
37
+ align-items: center;
38
+ gap: 8px;
39
+ color: var(--text-primary, #e0e0e0);
40
+ }
41
+
42
+ .closeBtn {
43
+ background: none;
44
+ border: none;
45
+ color: var(--text-muted, #888);
46
+ font-size: 18px;
47
+ cursor: pointer;
48
+ padding: 4px 8px;
49
+ border-radius: 6px;
50
+ &:hover { background: var(--bg-hover, #1a1a2e); color: var(--text-primary, #e0e0e0); }
51
+ }
52
+
53
+ .body {
54
+ padding: 20px;
55
+ overflow-y: auto;
56
+ flex: 1;
57
+ }
58
+
59
+ /* Hero Card */
60
+ .heroCard {
61
+ display: flex;
62
+ align-items: center;
63
+ justify-content: space-between;
64
+ padding: 16px;
65
+ border-radius: 10px;
66
+ margin-bottom: 16px;
67
+ }
68
+
69
+ .heroRunning {
70
+ background: linear-gradient(135deg, rgba(0, 200, 100, 0.08), rgba(0, 200, 100, 0.02));
71
+ border: 1px solid rgba(0, 200, 100, 0.2);
72
+ }
73
+
74
+ .heroStopped {
75
+ background: var(--bg-secondary, #14141f);
76
+ border: 1px solid var(--border, #1e1e2e);
77
+ }
78
+
79
+ .heroLeft {
80
+ display: flex;
81
+ flex-direction: column;
82
+ gap: 4px;
83
+ }
84
+
85
+ .heroStatus {
86
+ display: flex;
87
+ align-items: center;
88
+ gap: 8px;
89
+ }
90
+
91
+ .statusDot {
92
+ width: 10px;
93
+ height: 10px;
94
+ border-radius: 50%;
95
+ display: inline-block;
96
+ }
97
+
98
+ .dotGreen {
99
+ background: #00c864;
100
+ box-shadow: 0 0 6px rgba(0, 200, 100, 0.4);
101
+ }
102
+
103
+ .dotGray {
104
+ background: #555;
105
+ }
106
+
107
+ .heroLabel {
108
+ font-size: 15px;
109
+ font-weight: 600;
110
+ color: var(--text-primary, #e0e0e0);
111
+ }
112
+
113
+ .heroMeta {
114
+ font-size: 12px;
115
+ color: var(--text-muted, #888);
116
+ display: flex;
117
+ align-items: center;
118
+ gap: 6px;
119
+ }
120
+
121
+ .heroActions {
122
+ display: flex;
123
+ align-items: center;
124
+ gap: 8px;
125
+ }
126
+
127
+ .startBtn {
128
+ display: flex;
129
+ align-items: center;
130
+ gap: 6px;
131
+ padding: 8px 16px;
132
+ background: #00c864;
133
+ color: #000;
134
+ border: none;
135
+ border-radius: 8px;
136
+ font-size: 13px;
137
+ font-weight: 600;
138
+ cursor: pointer;
139
+ &:hover { background: #00d874; }
140
+ &:disabled { opacity: 0.5; cursor: not-allowed; }
141
+ }
142
+
143
+ .stopBtn {
144
+ display: flex;
145
+ align-items: center;
146
+ gap: 6px;
147
+ padding: 8px 16px;
148
+ background: #dc3545;
149
+ color: #fff;
150
+ border: none;
151
+ border-radius: 8px;
152
+ font-size: 13px;
153
+ font-weight: 600;
154
+ cursor: pointer;
155
+ &:hover { background: #e04555; }
156
+ &:disabled { opacity: 0.5; cursor: not-allowed; }
157
+ }
158
+
159
+ .refreshBtn {
160
+ display: flex;
161
+ align-items: center;
162
+ justify-content: center;
163
+ width: 32px;
164
+ height: 32px;
165
+ background: var(--bg-secondary, #14141f);
166
+ border: 1px solid var(--border, #1e1e2e);
167
+ border-radius: 8px;
168
+ color: var(--text-muted, #888);
169
+ cursor: pointer;
170
+ &:hover { color: var(--text-primary, #e0e0e0); background: var(--bg-hover, #1a1a2e); }
171
+ }
172
+
173
+ /* Quick Setup */
174
+ .quickSetup {
175
+ margin-bottom: 16px;
176
+ }
177
+
178
+ .sectionTitle {
179
+ display: flex;
180
+ align-items: center;
181
+ gap: 6px;
182
+ font-size: 13px;
183
+ font-weight: 600;
184
+ color: var(--text-secondary, #aaa);
185
+ margin: 0 0 8px 0;
186
+ text-transform: uppercase;
187
+ letter-spacing: 0.5px;
188
+ }
189
+
190
+ .quickRows {
191
+ display: flex;
192
+ flex-direction: column;
193
+ gap: 6px;
194
+ background: var(--bg-secondary, #14141f);
195
+ border: 1px solid var(--border, #1e1e2e);
196
+ border-radius: 8px;
197
+ padding: 10px 12px;
198
+ }
199
+
200
+ .quickRow {
201
+ display: flex;
202
+ align-items: center;
203
+ gap: 8px;
204
+ }
205
+
206
+ .quickLabel {
207
+ font-size: 11px;
208
+ font-weight: 600;
209
+ color: var(--text-muted, #888);
210
+ min-width: 60px;
211
+ text-transform: uppercase;
212
+ }
213
+
214
+ .quickValue {
215
+ flex: 1;
216
+ font-size: 13px;
217
+ color: var(--text-primary, #e0e0e0);
218
+ background: var(--bg-primary, #0f0f17);
219
+ padding: 4px 8px;
220
+ border-radius: 4px;
221
+ font-family: 'SF Mono', monospace;
222
+ }
223
+
224
+ .copyBtn {
225
+ background: none;
226
+ border: none;
227
+ color: var(--text-muted, #888);
228
+ cursor: pointer;
229
+ padding: 4px;
230
+ border-radius: 4px;
231
+ &:hover { color: var(--text-primary, #e0e0e0); background: var(--bg-hover, #1a1a2e); }
232
+ }
233
+
234
+ /* Probe Mode */
235
+ .section {
236
+ margin-bottom: 16px;
237
+ }
238
+
239
+ .probeModes {
240
+ display: flex;
241
+ gap: 8px;
242
+ }
243
+
244
+ .probeBtn {
245
+ padding: 6px 14px;
246
+ border: 1px solid var(--border, #1e1e2e);
247
+ border-radius: 6px;
248
+ background: var(--bg-secondary, #14141f);
249
+ color: var(--text-secondary, #aaa);
250
+ font-size: 12px;
251
+ cursor: pointer;
252
+ text-transform: capitalize;
253
+ &:hover { border-color: var(--text-muted, #888); }
254
+ }
255
+
256
+ .probeActive {
257
+ background: rgba(0, 200, 100, 0.1);
258
+ border-color: rgba(0, 200, 100, 0.3);
259
+ color: #00c864;
260
+ font-weight: 600;
261
+ }
262
+
263
+ /* Model Health */
264
+ .modelList {
265
+ display: flex;
266
+ flex-direction: column;
267
+ gap: 4px;
268
+ }
269
+
270
+ .modelRow {
271
+ display: flex;
272
+ align-items: center;
273
+ gap: 8px;
274
+ padding: 6px 10px;
275
+ background: var(--bg-secondary, #14141f);
276
+ border-radius: 6px;
277
+ font-size: 12px;
278
+ }
279
+
280
+ .modelPriority {
281
+ color: var(--text-muted, #888);
282
+ min-width: 22px;
283
+ font-weight: 600;
284
+ }
285
+
286
+ .modelName {
287
+ flex: 1;
288
+ color: var(--text-primary, #e0e0e0);
289
+ font-family: 'SF Mono', monospace;
290
+ overflow: hidden;
291
+ text-overflow: ellipsis;
292
+ white-space: nowrap;
293
+ }
294
+
295
+ .modelScore {
296
+ color: var(--text-muted, #888);
297
+ min-width: 40px;
298
+ text-align: right;
299
+ }
300
+
301
+ .modelLatency {
302
+ color: var(--text-muted, #888);
303
+ min-width: 50px;
304
+ text-align: right;
305
+ }
306
+
307
+ .circuitBadge {
308
+ font-size: 10px;
309
+ font-weight: 700;
310
+ padding: 2px 6px;
311
+ border-radius: 4px;
312
+ text-transform: uppercase;
313
+ letter-spacing: 0.5px;
314
+ min-width: 70px;
315
+ text-align: center;
316
+ }
317
+
318
+ .circuitClosed { background: rgba(0, 200, 100, 0.15); color: #00c864; }
319
+ .circuitOpen { background: rgba(220, 53, 69, 0.15); color: #dc3545; }
320
+ .circuitHalfOpen { background: rgba(255, 193, 7, 0.15); color: #ffc107; }
321
+ .circuitAuth { background: rgba(255, 140, 0, 0.15); color: #ff8c00; }
322
+ .circuitUnknown { background: rgba(128, 128, 128, 0.15); color: #888; }
323
+
324
+ /* Request Log */
325
+ .logList {
326
+ display: flex;
327
+ flex-direction: column;
328
+ gap: 2px;
329
+ }
330
+
331
+ .logRow {
332
+ display: flex;
333
+ align-items: center;
334
+ gap: 8px;
335
+ padding: 4px 8px;
336
+ font-size: 11px;
337
+ font-family: 'SF Mono', monospace;
338
+ }
339
+
340
+ .logOk { color: #00c864; min-width: 28px; }
341
+ .logErr { color: #dc3545; min-width: 28px; }
342
+ .logModel { color: var(--text-secondary, #aaa); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
343
+ .logLatency { color: var(--text-muted, #888); min-width: 50px; text-align: right; }
344
+ .logTokens { color: var(--text-muted, #888); min-width: 60px; text-align: right; }
345
+ .logFailover {
346
+ font-size: 9px;
347
+ padding: 1px 4px;
348
+ background: rgba(255, 193, 7, 0.15);
349
+ color: #ffc107;
350
+ border-radius: 3px;
351
+ text-transform: uppercase;
352
+ }
353
+
354
+ @keyframes fadeIn {
355
+ from { opacity: 0; }
356
+ to { opacity: 1; }
357
+ }