free-coding-models 0.5.1 → 0.5.4
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.
- package/README.md +31 -5
- package/changelog/v0.5.4.md +24 -0
- package/package.json +5 -2
- package/sources.js +2 -0
- package/src/tui/key-handler.js +1 -1
- package/web/assets/providers/aistudio/aistudio-text.svg +1 -0
- package/web/assets/providers/aistudio/aistudio.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-brand-color.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-brand.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-color.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-text.svg +1 -0
- package/web/assets/providers/cerebras/cerebras.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare-color.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare-text.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare.svg +1 -0
- package/web/assets/providers/gemini/gemini-color.svg +1 -0
- package/web/assets/providers/gemini/gemini-text.svg +1 -0
- package/web/assets/providers/gemini/gemini.svg +1 -0
- package/web/assets/providers/github/github-text.svg +1 -0
- package/web/assets/providers/github/github.svg +1 -0
- package/web/assets/providers/groq/groq-text.svg +1 -0
- package/web/assets/providers/groq/groq.svg +1 -0
- package/web/assets/providers/mistral/mistral-color.svg +1 -0
- package/web/assets/providers/mistral/mistral-text.svg +1 -0
- package/web/assets/providers/mistral/mistral.svg +1 -0
- package/web/assets/providers/nvidia/nvidia-color.svg +1 -0
- package/web/assets/providers/nvidia/nvidia-text.svg +1 -0
- package/web/assets/providers/nvidia/nvidia.svg +1 -0
- package/web/assets/providers/opencode/opencode-text.svg +1 -0
- package/web/assets/providers/opencode/opencode.svg +1 -0
- package/web/assets/providers/openrouter/openrouter (1).svg +1 -0
- package/web/assets/providers/openrouter/openrouter-text.svg +1 -0
- package/web/assets/providers/openrouter/openrouter.svg +1 -0
- package/web/assets/providers/ovhcloud/ovhcloud.svg +12 -0
- package/web/assets/providers/qwen/qwen-color.svg +1 -0
- package/web/assets/providers/qwen/qwen-text.svg +1 -0
- package/web/assets/providers/qwen/qwen.svg +1 -0
- package/web/assets/providers/sambanova/sambanova-color.svg +1 -0
- package/web/assets/providers/sambanova/sambanova-text.svg +1 -0
- package/web/assets/providers/sambanova/sambanova.svg +1 -0
- package/web/assets/providers/scalewaylogo/ScalewayLogo.svg +13 -0
- package/web/assets/providers/zai/zai-text.svg +1 -0
- package/web/assets/providers/zai/zai.svg +1 -0
- package/web/dist/assets/index-BoWmUveV.js +39 -0
- package/web/dist/assets/index-BrpHevg4.css +1 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/favicons/apple-touch-icon.png +0 -0
- package/web/dist/favicons/browserconfig.xml +12 -0
- package/web/dist/favicons/favicon-16x16.png +0 -0
- package/web/dist/favicons/favicon-192x192.png +0 -0
- package/web/dist/favicons/favicon-32x32.png +0 -0
- package/web/dist/favicons/favicon-48x48.png +0 -0
- package/web/dist/favicons/favicon-512x512.png +0 -0
- package/web/dist/favicons/favicon-96x96.png +0 -0
- package/web/dist/favicons/favicon.ico +0 -0
- package/web/dist/favicons/mstile-150x150.png +0 -0
- package/web/dist/favicons/mstile-310x150.png +0 -0
- package/web/dist/favicons/mstile-310x310.png +0 -0
- package/web/dist/favicons/mstile-512x512.png +0 -0
- package/web/dist/favicons/mstile-70x70.png +0 -0
- package/web/dist/favicons/site.webmanifest +25 -0
- package/web/dist/index.html +42 -3
- package/web/index.html +40 -1
- package/web/public/favicon.ico +0 -0
- package/web/public/favicons/apple-touch-icon.png +0 -0
- package/web/public/favicons/browserconfig.xml +12 -0
- package/web/public/favicons/favicon-16x16.png +0 -0
- package/web/public/favicons/favicon-192x192.png +0 -0
- package/web/public/favicons/favicon-32x32.png +0 -0
- package/web/public/favicons/favicon-48x48.png +0 -0
- package/web/public/favicons/favicon-512x512.png +0 -0
- package/web/public/favicons/favicon-96x96.png +0 -0
- package/web/public/favicons/favicon.ico +0 -0
- package/web/public/favicons/mstile-150x150.png +0 -0
- package/web/public/favicons/mstile-310x150.png +0 -0
- package/web/public/favicons/mstile-310x310.png +0 -0
- package/web/public/favicons/mstile-512x512.png +0 -0
- package/web/public/favicons/mstile-70x70.png +0 -0
- package/web/public/favicons/site.webmanifest +25 -0
- package/web/server.js +71 -1
- package/web/src/App.jsx +209 -89
- package/web/src/components/analytics/AnalyticsView.jsx +30 -2
- package/web/src/components/analytics/AnalyticsView.module.css +4 -0
- package/web/src/components/atoms/AILatencyCell.module.css +9 -1
- package/web/src/components/atoms/HealthCell.jsx +113 -28
- package/web/src/components/atoms/HealthCell.module.css +43 -2
- package/web/src/components/atoms/LastPingCell.module.css +10 -0
- package/web/src/components/atoms/NoKeyIcon.jsx +65 -0
- package/web/src/components/atoms/NoKeyIcon.module.css +30 -0
- package/web/src/components/atoms/ProviderLogo.jsx +188 -0
- package/web/src/components/atoms/ProviderLogo.module.css +103 -0
- package/web/src/components/atoms/Sparkline.jsx +21 -2
- package/web/src/components/atoms/StabilityCell.module.css +5 -0
- package/web/src/components/atoms/StatusDot.module.css +7 -0
- package/web/src/components/atoms/TPSCell.module.css +7 -1
- package/web/src/components/atoms/TierBadge.module.css +11 -0
- package/web/src/components/atoms/Toast.module.css +6 -0
- package/web/src/components/atoms/VerdictBadge.module.css +12 -0
- package/web/src/components/dashboard/DetailPanel.jsx +63 -2
- package/web/src/components/dashboard/DetailPanel.module.css +130 -0
- package/web/src/components/dashboard/FilterBar.jsx +146 -53
- package/web/src/components/dashboard/FilterBar.module.css +138 -58
- package/web/src/components/dashboard/ModelTable.jsx +235 -30
- package/web/src/components/dashboard/ModelTable.module.css +205 -16
- package/web/src/components/layout/Header.jsx +140 -12
- package/web/src/components/layout/Header.module.css +191 -27
- package/web/src/components/palette/CommandPalette.jsx +146 -0
- package/web/src/components/palette/CommandPalette.module.css +121 -0
- package/web/src/global.css +52 -15
- package/web/src/hooks/useColumnSizing.js +211 -0
- package/web/src/hooks/useFavorites.js +150 -0
- package/web/src/hooks/useFilter.js +157 -24
- package/web/src/hooks/useTheme.js +72 -16
- package/web/src/hooks/useUrlState.js +93 -0
- package/web/dist/assets/index-ByGf4Kq-.js +0 -14
- package/web/dist/assets/index-Ds7wmHBv.css +0 -1
- package/web/src/components/dashboard/StatsBar.jsx +0 -42
- package/web/src/components/dashboard/StatsBar.module.css +0 -28
- package/web/src/components/layout/Sidebar.jsx +0 -50
- package/web/src/components/layout/Sidebar.module.css +0 -86
- package/web/src/components/map/MapView.jsx +0 -17
- package/web/src/components/map/MapView.module.css +0 -25
package/web/src/App.jsx
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file web/src/App.jsx
|
|
3
|
-
* @description Root application component — orchestrates all views,
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* @description Root application component — orchestrates all views, header nav, Socket.IO
|
|
4
|
+
* connection, toast notifications, and global state. M1 layout refactor: no more left
|
|
5
|
+
* sidebar. Navigation lives in the Header (always-visible nav + overflow menu).
|
|
6
|
+
*
|
|
7
|
+
* 📖 M1 also wires in:
|
|
8
|
+
* - StatsBar above the ModelTable (was orphaned)
|
|
9
|
+
* - URL deep-linking (read-only — ?tier=…&sort=…&origin=…)
|
|
10
|
+
* - ⌘K command palette (placeholder modal for M1, real palette in M2)
|
|
11
|
+
* - Favorites hook (per-row star + reorder + display mode toggle)
|
|
12
|
+
* - Per-model benchmark button (uses existing /api/benchmark)
|
|
13
|
+
*
|
|
7
14
|
* @functions App → root component with all state and layout composition
|
|
8
15
|
*/
|
|
9
|
-
import { useState, useCallback, useEffect } from 'react'
|
|
16
|
+
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
10
17
|
import { useSocket } from './hooks/useSocket.js'
|
|
11
18
|
import { useFilter } from './hooks/useFilter.js'
|
|
12
19
|
import { useTheme } from './hooks/useTheme.js'
|
|
20
|
+
import { useFavorites } from './hooks/useFavorites.js'
|
|
21
|
+
import { useUrlState } from './hooks/useUrlState.js'
|
|
13
22
|
import Header from './components/layout/Header.jsx'
|
|
14
|
-
import Sidebar from './components/layout/Sidebar.jsx'
|
|
15
23
|
import Footer from './components/layout/Footer.jsx'
|
|
16
24
|
import FilterBar from './components/dashboard/FilterBar.jsx'
|
|
17
25
|
import ModelTable from './components/dashboard/ModelTable.jsx'
|
|
@@ -19,18 +27,43 @@ import DetailPanel from './components/dashboard/DetailPanel.jsx'
|
|
|
19
27
|
import ExportModal from './components/dashboard/ExportModal.jsx'
|
|
20
28
|
import SettingsView from './components/settings/SettingsView.jsx'
|
|
21
29
|
import AnalyticsView from './components/analytics/AnalyticsView.jsx'
|
|
22
|
-
import
|
|
30
|
+
import CommandPalette from './components/palette/CommandPalette.jsx'
|
|
23
31
|
import ToastContainer from './components/atoms/ToastContainer.jsx'
|
|
24
32
|
|
|
25
33
|
let toastIdCounter = 0
|
|
26
34
|
|
|
35
|
+
// 📖 Map current view to the header nav id. M1 only ships dashboard/settings/analytics;
|
|
36
|
+
// 📖 recommend/router/help/changelog/install-endpoints/installed-models are wired but
|
|
37
|
+
// 📖 still show a "Coming in M2/M3/M4" toast from the header for now.
|
|
38
|
+
const VIEW_TO_NAV = {
|
|
39
|
+
dashboard: 'dashboard',
|
|
40
|
+
settings: 'settings',
|
|
41
|
+
analytics: 'analytics',
|
|
42
|
+
recommend: 'recommend',
|
|
43
|
+
router: 'router',
|
|
44
|
+
help: 'help',
|
|
45
|
+
changelog: 'changelog',
|
|
46
|
+
'install-endpoints': 'install-endpoints',
|
|
47
|
+
'installed-models': 'installed-models',
|
|
48
|
+
}
|
|
49
|
+
|
|
27
50
|
export default function App() {
|
|
28
51
|
const { models, connected, nextPingAt, isPinging, pingMode, globalBenchmarkRunning, globalBenchmarkTotal, globalBenchmarkCompleted } = useSocket()
|
|
29
|
-
const { theme,
|
|
52
|
+
const { theme, cycle: cycleTheme } = useTheme()
|
|
30
53
|
const [currentView, setCurrentView] = useState('dashboard')
|
|
31
54
|
const [selectedModel, setSelectedModel] = useState(null)
|
|
32
55
|
const [exportOpen, setExportOpen] = useState(false)
|
|
56
|
+
const [paletteOpen, setPaletteOpen] = useState(false)
|
|
33
57
|
const [toasts, setToasts] = useState([])
|
|
58
|
+
const lastActivityRef = useRef(Date.now())
|
|
59
|
+
|
|
60
|
+
// 📖 URL deep-linking (M1 = read-only hydration on mount; write-back lands in M2).
|
|
61
|
+
// 📖 Syncs `currentView`, filter state, and selection from query params so users
|
|
62
|
+
// 📖 can share pre-configured dashboard URLs.
|
|
63
|
+
useUrlState({
|
|
64
|
+
currentView, setCurrentView,
|
|
65
|
+
filterState: null, // wired in M2
|
|
66
|
+
})
|
|
34
67
|
|
|
35
68
|
const {
|
|
36
69
|
filtered,
|
|
@@ -39,8 +72,17 @@ export default function App() {
|
|
|
39
72
|
filterProvider, setFilterProvider,
|
|
40
73
|
searchQuery, setSearchQuery,
|
|
41
74
|
sortColumn, sortDirection, toggleSort,
|
|
75
|
+
filterVerdict, setFilterVerdict,
|
|
76
|
+
filterHealth, setFilterHealth,
|
|
77
|
+
visibilityMode, setVisibilityMode,
|
|
78
|
+
customTextFilter, setCustomTextFilter,
|
|
79
|
+
resetView,
|
|
42
80
|
} = useFilter(models)
|
|
43
81
|
|
|
82
|
+
// 📖 Favorites — single source of truth shared with the TUI via ~/.free-coding-models.json.
|
|
83
|
+
const favorites = useFavorites({ models })
|
|
84
|
+
|
|
85
|
+
// 📖 Build the provider list for the FilterBar dropdown.
|
|
44
86
|
const providers = (() => {
|
|
45
87
|
const map = {}
|
|
46
88
|
models.forEach((m) => {
|
|
@@ -50,18 +92,13 @@ export default function App() {
|
|
|
50
92
|
return Object.values(map).sort((a, b) => a.name.localeCompare(b.name))
|
|
51
93
|
})()
|
|
52
94
|
|
|
53
|
-
// ── Global AI Speed
|
|
95
|
+
// ── Global benchmark (AI Speed Test) ─────────────────────────────────────
|
|
54
96
|
const handleBenchmark = useCallback(async () => {
|
|
55
|
-
if (globalBenchmarkRunning)
|
|
56
|
-
console.warn('[Benchmark] Global benchmark already in progress.')
|
|
57
|
-
return
|
|
58
|
-
}
|
|
97
|
+
if (globalBenchmarkRunning) return
|
|
59
98
|
try {
|
|
60
99
|
await fetch('/api/global-benchmark', {
|
|
61
100
|
method: 'POST',
|
|
62
101
|
headers: { 'Content-Type': 'application/json' },
|
|
63
|
-
// 📖 Match the user's current table, not the hidden catalog. If filters/search
|
|
64
|
-
// 📖 hide a model, the benchmark intentionally skips it.
|
|
65
102
|
body: JSON.stringify({
|
|
66
103
|
models: filtered.map((model) => ({ providerKey: model.providerKey, modelId: model.modelId })),
|
|
67
104
|
}),
|
|
@@ -71,118 +108,186 @@ export default function App() {
|
|
|
71
108
|
}
|
|
72
109
|
}, [filtered, globalBenchmarkRunning])
|
|
73
110
|
|
|
111
|
+
// ── Per-model benchmark (M1 parity with TUI Ctrl+A) ──────────────────────
|
|
112
|
+
const handleBenchmarkRow = useCallback(async (model) => {
|
|
113
|
+
try {
|
|
114
|
+
const resp = await fetch('/api/benchmark', {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { 'Content-Type': 'application/json' },
|
|
117
|
+
body: JSON.stringify({ providerKey: model.providerKey, modelId: model.modelId }),
|
|
118
|
+
})
|
|
119
|
+
if (!resp.ok && resp.status !== 202) {
|
|
120
|
+
const err = await resp.json().catch(() => ({}))
|
|
121
|
+
addToast?.(`Benchmark failed: ${err?.error || resp.statusText}`, 'error')
|
|
122
|
+
}
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.error('[Benchmark] per-row failed:', err.message)
|
|
125
|
+
}
|
|
126
|
+
}, [])
|
|
127
|
+
|
|
128
|
+
// ── Toast helpers ────────────────────────────────────────────────────────
|
|
74
129
|
const addToast = useCallback((message, type = 'info') => {
|
|
75
130
|
const id = ++toastIdCounter
|
|
76
131
|
setToasts((prev) => [...prev, { id, message, type }])
|
|
132
|
+
setTimeout(() => {
|
|
133
|
+
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
134
|
+
}, 4000)
|
|
77
135
|
}, [])
|
|
78
136
|
|
|
79
137
|
const dismissToast = useCallback((id) => {
|
|
80
138
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
81
139
|
}, [])
|
|
82
140
|
|
|
141
|
+
// ── Selection / detail panel ─────────────────────────────────────────────
|
|
83
142
|
const handleSelectModel = useCallback((model) => {
|
|
84
143
|
setSelectedModel(model)
|
|
144
|
+
lastActivityRef.current = Date.now()
|
|
85
145
|
}, [])
|
|
86
146
|
|
|
87
147
|
const handleCloseDetail = useCallback(() => setSelectedModel(null), [])
|
|
88
148
|
|
|
89
|
-
// ── Ping mode
|
|
149
|
+
// ── Ping mode change → server → broadcast ───────────────────────────────
|
|
90
150
|
const handlePingModeChange = useCallback(async (mode) => {
|
|
91
151
|
try {
|
|
92
152
|
await fetch(`/api/ping-mode?action=${mode}`, { method: 'POST' })
|
|
93
153
|
} catch {}
|
|
94
154
|
}, [])
|
|
95
155
|
|
|
156
|
+
// ── Navigation handler (Header nav + overflow menu) ──────────────────────
|
|
157
|
+
const handleNavigate = useCallback((viewId) => {
|
|
158
|
+
setCurrentView(VIEW_TO_NAV[viewId] || viewId)
|
|
159
|
+
lastActivityRef.current = Date.now()
|
|
160
|
+
}, [])
|
|
161
|
+
|
|
162
|
+
// ── Reset view (N key equivalent) ────────────────────────────────────────
|
|
163
|
+
const handleResetView = useCallback(() => {
|
|
164
|
+
resetView()
|
|
165
|
+
setSearchQuery('')
|
|
166
|
+
addToast('View reset to defaults.', 'info')
|
|
167
|
+
}, [resetView, setSearchQuery, addToast])
|
|
168
|
+
|
|
169
|
+
// ── Keyboard shortcuts: only ⌘K / Ctrl+P for the palette ────────────────
|
|
96
170
|
useEffect(() => {
|
|
97
171
|
const handler = (e) => {
|
|
98
|
-
|
|
172
|
+
const cmdOrCtrl = e.metaKey || e.ctrlKey
|
|
173
|
+
// ⌘K / Ctrl+K → toggle command palette (the Web's only global shortcut)
|
|
174
|
+
if (cmdOrCtrl && (e.key === 'k' || e.key === 'K')) {
|
|
99
175
|
e.preventDefault()
|
|
100
|
-
|
|
176
|
+
setPaletteOpen((o) => !o)
|
|
177
|
+
return
|
|
101
178
|
}
|
|
179
|
+
// Ctrl+P is also accepted as a TUI-style alias for the same palette.
|
|
180
|
+
if (cmdOrCtrl && (e.key === 'p' || e.key === 'P') && !e.shiftKey) {
|
|
181
|
+
e.preventDefault()
|
|
182
|
+
setPaletteOpen((o) => !o)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
// Esc closes whatever is open.
|
|
102
186
|
if (e.key === 'Escape') {
|
|
103
|
-
|
|
104
|
-
|
|
187
|
+
if (paletteOpen) { setPaletteOpen(false); return }
|
|
188
|
+
if (selectedModel) { setSelectedModel(null); return }
|
|
189
|
+
if (exportOpen) { setExportOpen(false); return }
|
|
105
190
|
}
|
|
106
191
|
}
|
|
107
192
|
window.addEventListener('keydown', handler)
|
|
108
193
|
return () => window.removeEventListener('keydown', handler)
|
|
109
|
-
}, [
|
|
194
|
+
}, [paletteOpen, selectedModel, exportOpen])
|
|
195
|
+
|
|
196
|
+
// 📖 Reset view if URL contains the reset flag.
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
const params = new URLSearchParams(window.location.search)
|
|
199
|
+
if (params.get('reset') === '1') {
|
|
200
|
+
handleResetView()
|
|
201
|
+
const url = new URL(window.location.href)
|
|
202
|
+
url.searchParams.delete('reset')
|
|
203
|
+
window.history.replaceState({}, '', url.toString())
|
|
204
|
+
}
|
|
205
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
206
|
+
}, [])
|
|
110
207
|
|
|
111
208
|
return (
|
|
112
209
|
<>
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
210
|
+
<div className="app-shell">
|
|
211
|
+
<Header
|
|
212
|
+
searchQuery={searchQuery}
|
|
213
|
+
onSearchChange={setSearchQuery}
|
|
214
|
+
currentView={currentView}
|
|
215
|
+
onNavigate={handleNavigate}
|
|
216
|
+
onToggleTheme={cycleTheme}
|
|
217
|
+
onOpenExport={() => setExportOpen(true)}
|
|
218
|
+
onOpenCommandPalette={() => setPaletteOpen(true)}
|
|
219
|
+
onBenchmark={handleBenchmark}
|
|
220
|
+
benchmarkRunning={globalBenchmarkRunning}
|
|
221
|
+
benchmarkTotal={globalBenchmarkTotal}
|
|
222
|
+
benchmarkCompleted={globalBenchmarkCompleted}
|
|
223
|
+
modelsCount={filtered.length}
|
|
224
|
+
theme={theme}
|
|
225
|
+
onToast={addToast}
|
|
226
|
+
/>
|
|
227
|
+
|
|
228
|
+
<div className="app-content">
|
|
229
|
+
{currentView === 'dashboard' && (
|
|
230
|
+
<div className="view">
|
|
231
|
+
<FilterBar
|
|
232
|
+
filterTier={filterTier}
|
|
233
|
+
setFilterTier={setFilterTier}
|
|
234
|
+
filterStatus={filterStatus}
|
|
235
|
+
setFilterStatus={setFilterStatus}
|
|
236
|
+
filterProvider={filterProvider}
|
|
237
|
+
setFilterProvider={setFilterProvider}
|
|
238
|
+
filterVerdict={filterVerdict}
|
|
239
|
+
setFilterVerdict={setFilterVerdict}
|
|
240
|
+
filterHealth={filterHealth}
|
|
241
|
+
setFilterHealth={setFilterHealth}
|
|
242
|
+
visibilityMode={visibilityMode}
|
|
243
|
+
setVisibilityMode={setVisibilityMode}
|
|
244
|
+
customTextFilter={customTextFilter}
|
|
245
|
+
setCustomTextFilter={setCustomTextFilter}
|
|
246
|
+
searchQuery={searchQuery}
|
|
247
|
+
onResetView={handleResetView}
|
|
248
|
+
providers={providers}
|
|
249
|
+
pingMode={pingMode}
|
|
250
|
+
setPingMode={handlePingModeChange}
|
|
251
|
+
nextPingAt={nextPingAt}
|
|
252
|
+
isPinging={isPinging}
|
|
253
|
+
globalBenchmarkRunning={globalBenchmarkRunning}
|
|
254
|
+
globalBenchmarkTotal={globalBenchmarkTotal}
|
|
255
|
+
globalBenchmarkCompleted={globalBenchmarkCompleted}
|
|
256
|
+
/>
|
|
257
|
+
<ModelTable
|
|
258
|
+
filtered={filtered}
|
|
259
|
+
onSelectModel={handleSelectModel}
|
|
260
|
+
onBenchmarkRow={handleBenchmarkRow}
|
|
261
|
+
favorites={favorites}
|
|
262
|
+
sortColumn={sortColumn}
|
|
263
|
+
sortDirection={sortDirection}
|
|
264
|
+
onSort={toggleSort}
|
|
265
|
+
/>
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
|
|
269
|
+
{currentView === 'settings' && (
|
|
270
|
+
<div className="view">
|
|
271
|
+
<SettingsView onToast={addToast} />
|
|
272
|
+
</div>
|
|
273
|
+
)}
|
|
274
|
+
|
|
275
|
+
{currentView === 'analytics' && (
|
|
276
|
+
<div className="view">
|
|
277
|
+
<AnalyticsView models={models} />
|
|
278
|
+
</div>
|
|
279
|
+
)}
|
|
119
280
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
<div className="view">
|
|
123
|
-
<Header
|
|
124
|
-
searchQuery={searchQuery}
|
|
125
|
-
onSearchChange={setSearchQuery}
|
|
126
|
-
onToggleTheme={toggleTheme}
|
|
127
|
-
onOpenSettings={() => setCurrentView('settings')}
|
|
128
|
-
onOpenExport={() => setExportOpen(true)}
|
|
129
|
-
onBenchmark={handleBenchmark}
|
|
130
|
-
benchmarkRunning={globalBenchmarkRunning}
|
|
131
|
-
benchmarkTotal={globalBenchmarkTotal}
|
|
132
|
-
benchmarkCompleted={globalBenchmarkCompleted}
|
|
133
|
-
modelsCount={filtered.length}
|
|
134
|
-
theme={theme}
|
|
135
|
-
/>
|
|
136
|
-
<FilterBar
|
|
137
|
-
filterTier={filterTier}
|
|
138
|
-
setFilterTier={setFilterTier}
|
|
139
|
-
filterStatus={filterStatus}
|
|
140
|
-
setFilterStatus={setFilterStatus}
|
|
141
|
-
filterProvider={filterProvider}
|
|
142
|
-
setFilterProvider={setFilterProvider}
|
|
143
|
-
providers={providers}
|
|
144
|
-
pingMode={pingMode}
|
|
145
|
-
setPingMode={handlePingModeChange}
|
|
146
|
-
nextPingAt={nextPingAt}
|
|
147
|
-
isPinging={isPinging}
|
|
148
|
-
globalBenchmarkRunning={globalBenchmarkRunning}
|
|
149
|
-
globalBenchmarkTotal={globalBenchmarkTotal}
|
|
150
|
-
globalBenchmarkCompleted={globalBenchmarkCompleted}
|
|
151
|
-
/>
|
|
152
|
-
<ModelTable
|
|
153
|
-
filtered={filtered}
|
|
154
|
-
onSelectModel={handleSelectModel}
|
|
155
|
-
sortColumn={sortColumn}
|
|
156
|
-
sortDirection={sortDirection}
|
|
157
|
-
onSort={toggleSort}
|
|
158
|
-
/>
|
|
159
|
-
</div>
|
|
160
|
-
)}
|
|
161
|
-
|
|
162
|
-
{currentView === 'settings' && (
|
|
163
|
-
<div className="view">
|
|
164
|
-
<SettingsView onToast={addToast} />
|
|
165
|
-
</div>
|
|
166
|
-
)}
|
|
167
|
-
|
|
168
|
-
{currentView === 'analytics' && (
|
|
169
|
-
<div className="view">
|
|
170
|
-
<AnalyticsView models={models} />
|
|
171
|
-
</div>
|
|
172
|
-
)}
|
|
173
|
-
|
|
174
|
-
{currentView === 'map' && (
|
|
175
|
-
<div className="view">
|
|
176
|
-
<MapView />
|
|
177
|
-
</div>
|
|
178
|
-
)}
|
|
179
|
-
|
|
180
|
-
<Footer />
|
|
281
|
+
<Footer />
|
|
282
|
+
</div>
|
|
181
283
|
</div>
|
|
182
284
|
|
|
183
285
|
<DetailPanel
|
|
184
286
|
model={selectedModel}
|
|
185
287
|
onClose={handleCloseDetail}
|
|
288
|
+
favorites={favorites}
|
|
289
|
+
onBenchmark={handleBenchmarkRow}
|
|
290
|
+
onToast={addToast}
|
|
186
291
|
/>
|
|
187
292
|
|
|
188
293
|
{exportOpen && (
|
|
@@ -193,7 +298,22 @@ export default function App() {
|
|
|
193
298
|
/>
|
|
194
299
|
)}
|
|
195
300
|
|
|
301
|
+
{paletteOpen && (
|
|
302
|
+
<CommandPalette
|
|
303
|
+
onClose={() => setPaletteOpen(false)}
|
|
304
|
+
onNavigate={handleNavigate}
|
|
305
|
+
onCycleTheme={cycleTheme}
|
|
306
|
+
onResetView={handleResetView}
|
|
307
|
+
currentView={currentView}
|
|
308
|
+
theme={theme}
|
|
309
|
+
pingMode={pingMode}
|
|
310
|
+
onSetPingMode={handlePingModeChange}
|
|
311
|
+
onToast={addToast}
|
|
312
|
+
onExport={() => setExportOpen(true)}
|
|
313
|
+
/>
|
|
314
|
+
)}
|
|
315
|
+
|
|
196
316
|
<ToastContainer toasts={toasts} dismissToast={dismissToast} />
|
|
197
317
|
</>
|
|
198
318
|
)
|
|
199
|
-
}
|
|
319
|
+
}
|
|
@@ -9,10 +9,38 @@ import { IconActivity, IconTrophy } from '@tabler/icons-react'
|
|
|
9
9
|
import TierBadge from '../atoms/TierBadge.jsx'
|
|
10
10
|
import styles from './AnalyticsView.module.css'
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// 📖 TIER_COLORS is now derived from CSS custom properties so the chart fills
|
|
13
|
+
// 📖 swap to AA-contrast shades in light mode automatically. The keys stay
|
|
14
|
+
// 📖 the same; the values come from --tier-splus / --tier-s / etc.
|
|
15
|
+
function readTierColor(name, fallback) {
|
|
16
|
+
if (typeof window === 'undefined' || !window.document?.documentElement) return fallback
|
|
17
|
+
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
|
18
|
+
return v || fallback
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const TIER_COLOR_KEYS = {
|
|
22
|
+
'S+': '--tier-splus',
|
|
23
|
+
'S': '--tier-s',
|
|
24
|
+
'A+': '--tier-aplus',
|
|
25
|
+
'A': '--tier-a',
|
|
26
|
+
'A-': '--tier-aminus',
|
|
27
|
+
'B+': '--tier-bplus',
|
|
28
|
+
'B': '--tier-b',
|
|
29
|
+
'C': '--tier-c',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const TIER_COLOR_FALLBACKS = {
|
|
13
33
|
'S+': '#ffd700', S: '#ff8c00', 'A+': '#00c8ff', A: '#3ddc84',
|
|
14
34
|
'A-': '#7ecf7e', 'B+': '#a8a8c8', B: '#808098', C: '#606078',
|
|
15
35
|
}
|
|
36
|
+
|
|
37
|
+
// 📖 Resolve the tier color at render time. Memoizing the map per theme switch
|
|
38
|
+
// 📖 would be more efficient, but the cost is one getComputedStyle per tier and
|
|
39
|
+
// 📖 the chart only re-renders when `models` changes, so the extra work is
|
|
40
|
+
// 📖 imperceptible.
|
|
41
|
+
function tierColor(key) {
|
|
42
|
+
return readTierColor(TIER_COLOR_KEYS[key], TIER_COLOR_FALLBACKS[key])
|
|
43
|
+
}
|
|
16
44
|
const TIERS = ['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
|
|
17
45
|
|
|
18
46
|
export default function AnalyticsView({ models }) {
|
|
@@ -102,7 +130,7 @@ export default function AnalyticsView({ models }) {
|
|
|
102
130
|
<div key={tier} className={styles.tierItem}>
|
|
103
131
|
<div className={styles.tierBadge}><TierBadge tier={tier} /></div>
|
|
104
132
|
<div className={styles.tierBar}>
|
|
105
|
-
<div className={styles.tierFill} style={{ width: `${pct}%`, background:
|
|
133
|
+
<div className={styles.tierFill} style={{ width: `${pct}%`, background: tierColor(tier) }} />
|
|
106
134
|
</div>
|
|
107
135
|
<span className={styles.tierCount}>{count}</span>
|
|
108
136
|
</div>
|
|
@@ -98,6 +98,10 @@
|
|
|
98
98
|
.pctMedium { color: #ffaa00; }
|
|
99
99
|
.pctSlow { color: #ff4444; }
|
|
100
100
|
|
|
101
|
+
:global([data-theme="light"]) .pctFast { color: #008f4d; }
|
|
102
|
+
:global([data-theme="light"]) .pctMedium { color: #b86b00; }
|
|
103
|
+
:global([data-theme="light"]) .pctSlow { color: #c8143a; }
|
|
104
|
+
|
|
101
105
|
.leaderItem {
|
|
102
106
|
display: flex;
|
|
103
107
|
align-items: center;
|
|
@@ -40,4 +40,12 @@
|
|
|
40
40
|
border-radius: 50%;
|
|
41
41
|
animation: benchSpin 0.6s linear infinite;
|
|
42
42
|
flex-shrink: 0;
|
|
43
|
-
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* 📖 Light theme: swap the neon palette for darker, AA-contrast shades. The
|
|
46
|
+
📖 purple running color stays vibrant since it works on both backgrounds. */
|
|
47
|
+
:global([data-theme="light"]) .fast { color: #008f4d; }
|
|
48
|
+
:global([data-theme="light"]) .medium { color: #b86b00; }
|
|
49
|
+
:global([data-theme="light"]) .slow { color: #c8143a; }
|
|
50
|
+
:global([data-theme="light"]) .badge { color: #0e7490; }
|
|
51
|
+
:global([data-theme="light"]) .runningCell { background: rgba(125, 0, 180, 0.08); }
|
|
@@ -1,11 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file web/src/components/atoms/HealthCell.jsx
|
|
3
3
|
* @description Detailed health/status column — matches CLI Health column.
|
|
4
|
-
* 📖 Shows
|
|
5
|
-
*
|
|
4
|
+
* 📖 Shows the current provider status next to a Tabler icon that matches the
|
|
5
|
+
* 📖 state. The icon is color-coded so a quick glance is enough to know if
|
|
6
|
+
* 📖 the row is healthy, slow, down, blocked, etc.
|
|
7
|
+
* 📖
|
|
8
|
+
* 📖 Icon → state mapping:
|
|
9
|
+
* 📖 ✅ up → IconChecks (green)
|
|
10
|
+
* 📖 ⏳ timeout → IconHourglassOff (yellow)
|
|
11
|
+
* 📖 ⏳ pending → IconHourglass (yellow)
|
|
12
|
+
* 📖 🔐 AUTH FAIL → IconLock (red, bold)
|
|
13
|
+
* 📖 🔑 NO KEY → NoKeyIcon (yellow key + red slash)
|
|
14
|
+
* 📖 🔥 429 → IconFlame (red)
|
|
15
|
+
* 📖 🚫 404 → IconError404 (red)
|
|
16
|
+
* 📖 💥 500 → IconAlertOctagon (red)
|
|
17
|
+
* 📖 🔌 502 → IconPlug (red)
|
|
18
|
+
* 📖 🔒 503 → IconCloudOff (red)
|
|
19
|
+
* 📖 ⏰ 504 → IconClock (red)
|
|
20
|
+
* 📖 ❌ unknown → IconBan (red)
|
|
6
21
|
*/
|
|
22
|
+
import {
|
|
23
|
+
IconChecks,
|
|
24
|
+
IconHourglass,
|
|
25
|
+
IconHourglassOff,
|
|
26
|
+
IconLock,
|
|
27
|
+
IconFlame,
|
|
28
|
+
IconError404,
|
|
29
|
+
IconAlertOctagon,
|
|
30
|
+
IconPlug,
|
|
31
|
+
IconCloudOff,
|
|
32
|
+
IconClock,
|
|
33
|
+
IconBan,
|
|
34
|
+
} from '@tabler/icons-react'
|
|
35
|
+
import NoKeyIcon from './NoKeyIcon.jsx'
|
|
7
36
|
import styles from './HealthCell.module.css'
|
|
8
37
|
|
|
38
|
+
const ICON_SIZE = 12
|
|
39
|
+
const ICON_STROKE = 1.8
|
|
40
|
+
|
|
41
|
+
// 📖 Themed icon helper: same color palette as the rest of the Health cell text,
|
|
42
|
+
// 📖 so the icon + label always match. Reading the CSS var at render time keeps
|
|
43
|
+
// 📖 the dark/light palettes aligned with the rest of the dashboard.
|
|
44
|
+
const HEALTH_ICON_COLOR = {
|
|
45
|
+
success: 'var(--color-success, #00ff88)',
|
|
46
|
+
warning: 'var(--color-warning, #ffaa00)',
|
|
47
|
+
danger: 'var(--color-danger, #ff4444)',
|
|
48
|
+
dim: 'var(--color-text-dim, #444)',
|
|
49
|
+
}
|
|
50
|
+
|
|
9
51
|
const ERROR_LABELS = {
|
|
10
52
|
'404': '404 NOT FOUND',
|
|
11
53
|
'410': '410 GONE',
|
|
@@ -16,38 +58,81 @@ const ERROR_LABELS = {
|
|
|
16
58
|
'504': '504 TIMEOUT',
|
|
17
59
|
}
|
|
18
60
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
61
|
+
// 📖 Per-HTTP-code icon + color picker. Anything not in this map falls back to
|
|
62
|
+
// 📖 the generic down-state rendering below.
|
|
63
|
+
const ERROR_ICON = {
|
|
64
|
+
'429': { Icon: IconFlame, color: HEALTH_ICON_COLOR.danger },
|
|
65
|
+
'404': { Icon: IconError404, color: HEALTH_ICON_COLOR.danger },
|
|
66
|
+
'500': { Icon: IconAlertOctagon, color: HEALTH_ICON_COLOR.danger },
|
|
67
|
+
'502': { Icon: IconPlug, color: HEALTH_ICON_COLOR.danger },
|
|
68
|
+
'503': { Icon: IconCloudOff, color: HEALTH_ICON_COLOR.danger },
|
|
69
|
+
'504': { Icon: IconClock, color: HEALTH_ICON_COLOR.danger },
|
|
26
70
|
}
|
|
27
71
|
|
|
28
72
|
function statusLabel(status, httpCode) {
|
|
29
|
-
|
|
30
|
-
if (status === '
|
|
31
|
-
if (status === '
|
|
32
|
-
if (status === '
|
|
33
|
-
if (status === 'timeout') return `${emoji} TIMEOUT`
|
|
73
|
+
if (status === 'noauth') return 'NO KEY'
|
|
74
|
+
if (status === 'auth_error') return 'AUTH FAIL'
|
|
75
|
+
if (status === 'pending') return 'wait'
|
|
76
|
+
if (status === 'timeout') return 'TIMEOUT'
|
|
34
77
|
if (status === 'down') {
|
|
35
|
-
|
|
36
|
-
const errEmoji = { '429': '🔥', '404': '🚫', '500': '💥', '502': '🔌', '503': '🔒', '504': '⏰' }[httpCode] || '❌'
|
|
37
|
-
return `${errEmoji} ${label}`
|
|
78
|
+
return ERROR_LABELS[httpCode] || (httpCode || 'ERROR')
|
|
38
79
|
}
|
|
39
|
-
if (status === 'up') return
|
|
40
|
-
return
|
|
80
|
+
if (status === 'up') return 'UP'
|
|
81
|
+
return '?'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function statusClass(status) {
|
|
85
|
+
if (status === 'noauth') return styles.noKey
|
|
86
|
+
if (status === 'up') return styles.up
|
|
87
|
+
if (status === 'down') return styles.error
|
|
88
|
+
if (status === 'timeout') return styles.warning
|
|
89
|
+
if (status === 'auth_error') return styles.errorBold
|
|
90
|
+
if (status === 'pending') return styles.warning
|
|
91
|
+
return styles.dim
|
|
41
92
|
}
|
|
42
93
|
|
|
43
94
|
export default function HealthCell({ status, httpCode }) {
|
|
44
95
|
const text = statusLabel(status, httpCode)
|
|
45
|
-
const cls = status
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
96
|
+
const cls = statusClass(status)
|
|
97
|
+
const isNoKey = status === 'noauth'
|
|
98
|
+
|
|
99
|
+
// 📖 Pick the right icon + color combo for this state.
|
|
100
|
+
let Icon = null
|
|
101
|
+
let iconColor = null
|
|
102
|
+
if (status === 'up') {
|
|
103
|
+
Icon = IconChecks
|
|
104
|
+
iconColor = HEALTH_ICON_COLOR.success
|
|
105
|
+
} else if (status === 'timeout') {
|
|
106
|
+
Icon = IconHourglassOff
|
|
107
|
+
iconColor = HEALTH_ICON_COLOR.warning
|
|
108
|
+
} else if (status === 'pending') {
|
|
109
|
+
Icon = IconHourglass
|
|
110
|
+
iconColor = HEALTH_ICON_COLOR.warning
|
|
111
|
+
} else if (status === 'auth_error') {
|
|
112
|
+
Icon = IconLock
|
|
113
|
+
iconColor = HEALTH_ICON_COLOR.danger
|
|
114
|
+
} else if (status === 'down' && ERROR_ICON[httpCode]) {
|
|
115
|
+
Icon = ERROR_ICON[httpCode].Icon
|
|
116
|
+
iconColor = ERROR_ICON[httpCode].color
|
|
117
|
+
} else if (status === 'down') {
|
|
118
|
+
// 📖 Generic "down" fallback (no recognized HTTP code).
|
|
119
|
+
Icon = IconBan
|
|
120
|
+
iconColor = HEALTH_ICON_COLOR.danger
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<span className={`${styles.cell} ${cls}`}>
|
|
125
|
+
{isNoKey ? (
|
|
126
|
+
<NoKeyIcon size={15} title="No API key configured for this provider" />
|
|
127
|
+
) : Icon ? (
|
|
128
|
+
<Icon
|
|
129
|
+
size={ICON_SIZE}
|
|
130
|
+
stroke={ICON_STROKE}
|
|
131
|
+
style={{ color: iconColor, flexShrink: 0 }}
|
|
132
|
+
aria-hidden="true"
|
|
133
|
+
/>
|
|
134
|
+
) : null}
|
|
135
|
+
<span className={isNoKey ? styles.noKeyText : undefined}>{text}</span>
|
|
136
|
+
</span>
|
|
137
|
+
)
|
|
138
|
+
}
|