free-coding-models 0.3.37 → 0.3.40

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 (58) hide show
  1. package/CHANGELOG.md +10 -1794
  2. package/README.md +4 -1
  3. package/bin/free-coding-models.js +8 -0
  4. package/package.json +13 -3
  5. package/src/app.js +3 -0
  6. package/src/cli-help.js +2 -0
  7. package/src/command-palette.js +3 -0
  8. package/src/endpoint-installer.js +1 -1
  9. package/src/tool-bootstrap.js +34 -0
  10. package/src/tool-launchers.js +137 -1
  11. package/src/tool-metadata.js +9 -0
  12. package/src/utils.js +10 -0
  13. package/web/app.legacy.js +900 -0
  14. package/web/index.html +20 -0
  15. package/web/server.js +382 -0
  16. package/web/src/App.jsx +150 -0
  17. package/web/src/components/analytics/AnalyticsView.jsx +109 -0
  18. package/web/src/components/analytics/AnalyticsView.module.css +186 -0
  19. package/web/src/components/atoms/Sparkline.jsx +44 -0
  20. package/web/src/components/atoms/StabilityCell.jsx +18 -0
  21. package/web/src/components/atoms/StabilityCell.module.css +8 -0
  22. package/web/src/components/atoms/StatusDot.jsx +10 -0
  23. package/web/src/components/atoms/StatusDot.module.css +17 -0
  24. package/web/src/components/atoms/TierBadge.jsx +10 -0
  25. package/web/src/components/atoms/TierBadge.module.css +18 -0
  26. package/web/src/components/atoms/Toast.jsx +25 -0
  27. package/web/src/components/atoms/Toast.module.css +35 -0
  28. package/web/src/components/atoms/ToastContainer.jsx +16 -0
  29. package/web/src/components/atoms/ToastContainer.module.css +10 -0
  30. package/web/src/components/atoms/VerdictBadge.jsx +13 -0
  31. package/web/src/components/atoms/VerdictBadge.module.css +19 -0
  32. package/web/src/components/dashboard/DetailPanel.jsx +131 -0
  33. package/web/src/components/dashboard/DetailPanel.module.css +99 -0
  34. package/web/src/components/dashboard/ExportModal.jsx +79 -0
  35. package/web/src/components/dashboard/ExportModal.module.css +99 -0
  36. package/web/src/components/dashboard/FilterBar.jsx +73 -0
  37. package/web/src/components/dashboard/FilterBar.module.css +43 -0
  38. package/web/src/components/dashboard/ModelTable.jsx +86 -0
  39. package/web/src/components/dashboard/ModelTable.module.css +46 -0
  40. package/web/src/components/dashboard/StatsBar.jsx +40 -0
  41. package/web/src/components/dashboard/StatsBar.module.css +28 -0
  42. package/web/src/components/layout/Footer.jsx +19 -0
  43. package/web/src/components/layout/Footer.module.css +10 -0
  44. package/web/src/components/layout/Header.jsx +38 -0
  45. package/web/src/components/layout/Header.module.css +73 -0
  46. package/web/src/components/layout/Sidebar.jsx +41 -0
  47. package/web/src/components/layout/Sidebar.module.css +76 -0
  48. package/web/src/components/settings/SettingsView.jsx +264 -0
  49. package/web/src/components/settings/SettingsView.module.css +377 -0
  50. package/web/src/global.css +199 -0
  51. package/web/src/hooks/useFilter.js +83 -0
  52. package/web/src/hooks/useSSE.js +49 -0
  53. package/web/src/hooks/useTheme.js +27 -0
  54. package/web/src/main.jsx +15 -0
  55. package/web/src/utils/download.js +15 -0
  56. package/web/src/utils/format.js +42 -0
  57. package/web/src/utils/ranks.js +37 -0
  58. package/web/styles.legacy.css +963 -0
package/web/index.html ADDED
@@ -0,0 +1,20 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>free-coding-models — Live Dashboard</title>
7
+ <meta name="description" content="Find the fastest free coding LLM model in seconds. Live dashboard monitoring 230+ models across 24 providers.">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
11
+ </head>
12
+ <body>
13
+ <div class="bg-grid"></div>
14
+ <div class="bg-glow bg-glow--1"></div>
15
+ <div class="bg-glow bg-glow--2"></div>
16
+ <div class="bg-glow bg-glow--3"></div>
17
+ <div id="root"></div>
18
+ <script type="module" src="/src/main.jsx"></script>
19
+ </body>
20
+ </html>
package/web/server.js ADDED
@@ -0,0 +1,382 @@
1
+ /**
2
+ * @file web/server.js
3
+ * @description HTTP server for the free-coding-models Web Dashboard V2.
4
+ *
5
+ * Reuses the existing ping engine, model sources, and utility functions
6
+ * from the CLI tool. Serves the dashboard HTML/CSS/JS and provides
7
+ * API endpoints + SSE for real-time ping data.
8
+ *
9
+ * Endpoints:
10
+ * GET / → Dashboard HTML
11
+ * GET /styles.css → Dashboard styles
12
+ * GET /app.js → Dashboard client JS
13
+ * GET /api/models → All model metadata (JSON)
14
+ * GET /api/config → Current config (sanitized — masked keys)
15
+ * GET /api/key/:prov → Reveal a provider's full API key
16
+ * GET /api/events → SSE stream of live ping results
17
+ * POST /api/settings → Update API keys / provider toggles
18
+ */
19
+
20
+ import { createServer } from 'node:http'
21
+ import { readFileSync, existsSync } from 'node:fs'
22
+ import { join, dirname, extname } from 'node:path'
23
+ import { fileURLToPath } from 'node:url'
24
+ import { exec } from 'node:child_process'
25
+
26
+ import { sources, MODELS } from '../sources.js'
27
+ import { loadConfig, getApiKey, saveConfig, isProviderEnabled } from '../src/config.js'
28
+ import { ping } from '../src/ping.js'
29
+ import {
30
+ getAvg, getVerdict, getUptime, getP95, getJitter,
31
+ getStabilityScore, TIER_ORDER
32
+ } from '../src/utils.js'
33
+
34
+ const __dirname = dirname(fileURLToPath(import.meta.url))
35
+
36
+ // ─── State ───────────────────────────────────────────────────────────────────
37
+
38
+ let config = loadConfig()
39
+
40
+ // Build results array from MODELS (same shape as the TUI)
41
+ const results = MODELS.map(([modelId, label, tier, sweScore, ctx, providerKey], idx) => ({
42
+ idx: idx + 1,
43
+ modelId,
44
+ label,
45
+ tier,
46
+ sweScore,
47
+ ctx,
48
+ providerKey,
49
+ status: 'pending',
50
+ pings: [],
51
+ httpCode: null,
52
+ origin: sources[providerKey]?.name || providerKey,
53
+ url: sources[providerKey]?.url || null,
54
+ cliOnly: sources[providerKey]?.cliOnly || false,
55
+ zenOnly: sources[providerKey]?.zenOnly || false,
56
+ }))
57
+
58
+ // SSE clients
59
+ const sseClients = new Set()
60
+
61
+ // ─── Ping Loop ───────────────────────────────────────────────────────────────
62
+ // Uses recursive setTimeout (not setInterval) to prevent overlapping rounds.
63
+ // Each new round starts only after the previous one completes.
64
+
65
+ let pingRound = 0
66
+ let pingLoopRunning = false
67
+
68
+ async function pingAllModels() {
69
+ if (pingLoopRunning) return // guard against overlapping calls
70
+ pingLoopRunning = true
71
+ pingRound++
72
+ const batchSize = 30
73
+ // P2 fix: honor provider enabled flags — skip disabled providers
74
+ const modelsToPing = results.filter(r =>
75
+ !r.cliOnly && r.url && isProviderEnabled(config, r.providerKey)
76
+ )
77
+
78
+ for (let i = 0; i < modelsToPing.length; i += batchSize) {
79
+ const batch = modelsToPing.slice(i, i + batchSize)
80
+ const promises = batch.map(async (r) => {
81
+ const apiKey = getApiKey(config, r.providerKey)
82
+ try {
83
+ const result = await ping(apiKey, r.modelId, r.providerKey, r.url)
84
+ r.httpCode = result.code
85
+ if (result.code === '200') {
86
+ r.status = 'up'
87
+ r.pings.push({ ms: result.ms, code: result.code })
88
+ } else if (result.code === '401') {
89
+ r.status = 'up'
90
+ r.pings.push({ ms: result.ms, code: result.code })
91
+ } else if (result.code === '429') {
92
+ r.status = 'up'
93
+ r.pings.push({ ms: result.ms, code: result.code })
94
+ } else if (result.code === '000') {
95
+ r.status = 'timeout'
96
+ } else {
97
+ r.status = 'down'
98
+ r.pings.push({ ms: result.ms, code: result.code })
99
+ }
100
+ // Keep only last 60 pings
101
+ if (r.pings.length > 60) r.pings = r.pings.slice(-60)
102
+ } catch {
103
+ r.status = 'timeout'
104
+ }
105
+ })
106
+ await Promise.all(promises)
107
+ }
108
+
109
+ // Broadcast update to all SSE clients
110
+ broadcastUpdate()
111
+ pingLoopRunning = false
112
+ }
113
+
114
+ function broadcastUpdate() {
115
+ const data = JSON.stringify(getModelsPayload())
116
+ for (const client of sseClients) {
117
+ try {
118
+ client.write(`data: ${data}\n\n`)
119
+ } catch {
120
+ sseClients.delete(client)
121
+ }
122
+ }
123
+ }
124
+
125
+ function getModelsPayload() {
126
+ return results.map(r => ({
127
+ idx: r.idx,
128
+ modelId: r.modelId,
129
+ label: r.label,
130
+ tier: r.tier,
131
+ sweScore: r.sweScore,
132
+ ctx: r.ctx,
133
+ providerKey: r.providerKey,
134
+ origin: r.origin,
135
+ status: r.status,
136
+ httpCode: r.httpCode,
137
+ cliOnly: r.cliOnly,
138
+ zenOnly: r.zenOnly,
139
+ avg: getAvg(r),
140
+ verdict: getVerdict(r),
141
+ uptime: getUptime(r),
142
+ p95: getP95(r),
143
+ jitter: getJitter(r),
144
+ stability: getStabilityScore(r),
145
+ latestPing: r.pings.length > 0 ? r.pings[r.pings.length - 1].ms : null,
146
+ latestCode: r.pings.length > 0 ? r.pings[r.pings.length - 1].code : null,
147
+ pingHistory: r.pings.slice(-20).map(p => ({ ms: p.ms, code: p.code })),
148
+ pingCount: r.pings.length,
149
+ hasApiKey: !!getApiKey(config, r.providerKey),
150
+ }))
151
+ }
152
+
153
+ function getConfigPayload() {
154
+ // Sanitize — show which providers have keys, but not the actual keys
155
+ const providers = {}
156
+ for (const [key, src] of Object.entries(sources)) {
157
+ const rawKey = getApiKey(config, key)
158
+ providers[key] = {
159
+ name: src.name,
160
+ hasKey: !!rawKey,
161
+ maskedKey: rawKey ? maskApiKey(rawKey) : null,
162
+ enabled: isProviderEnabled(config, key),
163
+ modelCount: src.models?.length || 0,
164
+ cliOnly: src.cliOnly || false,
165
+ }
166
+ }
167
+ return { providers, totalModels: MODELS.length }
168
+ }
169
+
170
+ function maskApiKey(key) {
171
+ if (!key || typeof key !== 'string') return ''
172
+ if (key.length <= 8) return '••••••••'
173
+ return '••••••••' + key.slice(-4)
174
+ }
175
+
176
+ // ─── HTTP Server ─────────────────────────────────────────────────────────────
177
+
178
+ const MIME_TYPES = {
179
+ '.html': 'text/html; charset=utf-8',
180
+ '.css': 'text/css; charset=utf-8',
181
+ '.js': 'application/javascript; charset=utf-8',
182
+ '.json': 'application/json; charset=utf-8',
183
+ '.svg': 'image/svg+xml',
184
+ '.png': 'image/png',
185
+ '.ico': 'image/x-icon',
186
+ }
187
+
188
+ function serveFile(res, filename, contentType) {
189
+ try {
190
+ const content = readFileSync(join(__dirname, filename), 'utf8')
191
+ res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' })
192
+ res.end(content)
193
+ } catch {
194
+ res.writeHead(404)
195
+ res.end('Not Found')
196
+ }
197
+ }
198
+
199
+ function serveDistFile(res, pathname) {
200
+ const filePath = join(__dirname, 'dist', pathname === '/' ? 'index.html' : pathname)
201
+ if (!existsSync(filePath)) {
202
+ serveFile(res, 'dist/index.html', 'text/html; charset=utf-8')
203
+ return
204
+ }
205
+ const ext = extname(filePath)
206
+ const ct = MIME_TYPES[ext] || 'application/octet-stream'
207
+ try {
208
+ const content = readFileSync(filePath)
209
+ res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable' })
210
+ res.end(content)
211
+ } catch {
212
+ res.writeHead(404)
213
+ res.end('Not Found')
214
+ }
215
+ }
216
+
217
+ function handleRequest(req, res) {
218
+ // CORS for local dev
219
+ res.setHeader('Access-Control-Allow-Origin', '*')
220
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
221
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
222
+
223
+ if (req.method === 'OPTIONS') {
224
+ res.writeHead(204)
225
+ res.end()
226
+ return
227
+ }
228
+
229
+ const url = new URL(req.url, `http://${req.headers.host}`)
230
+
231
+ // ─── API: Reveal full key for a provider ───
232
+ const keyMatch = url.pathname.match(/^\/api\/key\/(.+)$/)
233
+ if (keyMatch) {
234
+ const providerKey = decodeURIComponent(keyMatch[1])
235
+ const rawKey = getApiKey(config, providerKey)
236
+ res.writeHead(200, { 'Content-Type': 'application/json' })
237
+ res.end(JSON.stringify({ key: rawKey || null }))
238
+ return
239
+ }
240
+
241
+ switch (url.pathname) {
242
+ case '/':
243
+ serveDistFile(res, '/')
244
+ break
245
+
246
+ case '/styles.css':
247
+ case '/app.js':
248
+ serveDistFile(res, url.pathname)
249
+ break
250
+
251
+ default:
252
+ if (url.pathname.startsWith('/assets/') || url.pathname.endsWith('.js') || url.pathname.endsWith('.css')) {
253
+ serveDistFile(res, url.pathname)
254
+ break
255
+ }
256
+
257
+ if (!url.pathname.startsWith('/api/')) {
258
+ res.writeHead(404)
259
+ res.end('Not Found')
260
+ break
261
+ }
262
+
263
+ case '/api/models':
264
+ res.writeHead(200, { 'Content-Type': 'application/json' })
265
+ res.end(JSON.stringify(getModelsPayload()))
266
+ break
267
+
268
+ case '/api/config':
269
+ res.writeHead(200, { 'Content-Type': 'application/json' })
270
+ res.end(JSON.stringify(getConfigPayload()))
271
+ break
272
+
273
+ case '/api/events':
274
+ // SSE endpoint
275
+ res.writeHead(200, {
276
+ 'Content-Type': 'text/event-stream',
277
+ 'Cache-Control': 'no-cache',
278
+ 'Connection': 'keep-alive',
279
+ })
280
+ res.write(`data: ${JSON.stringify(getModelsPayload())}\n\n`)
281
+ sseClients.add(res)
282
+ req.on('close', () => sseClients.delete(res))
283
+ break
284
+
285
+ case '/api/settings':
286
+ if (req.method === 'POST') {
287
+ let body = ''
288
+ req.on('data', chunk => body += chunk)
289
+ req.on('end', () => {
290
+ try {
291
+ const settings = JSON.parse(body)
292
+ if (settings.apiKeys) {
293
+ for (const [key, value] of Object.entries(settings.apiKeys)) {
294
+ if (value) config.apiKeys[key] = value
295
+ else delete config.apiKeys[key]
296
+ }
297
+ }
298
+ if (settings.providers) {
299
+ for (const [key, value] of Object.entries(settings.providers)) {
300
+ if (!config.providers[key]) config.providers[key] = {}
301
+ config.providers[key].enabled = value.enabled !== false
302
+ }
303
+ }
304
+ // P2 fix: catch saveConfig failures and report to client
305
+ try {
306
+ saveConfig(config)
307
+ } catch (saveErr) {
308
+ res.writeHead(500, { 'Content-Type': 'application/json' })
309
+ res.end(JSON.stringify({ success: false, error: 'Failed to save config: ' + saveErr.message }))
310
+ return
311
+ }
312
+ res.writeHead(200, { 'Content-Type': 'application/json' })
313
+ res.end(JSON.stringify({ success: true }))
314
+ } catch (err) {
315
+ res.writeHead(400, { 'Content-Type': 'application/json' })
316
+ res.end(JSON.stringify({ error: err.message }))
317
+ }
318
+ })
319
+ } else {
320
+ res.writeHead(405)
321
+ res.end('Method Not Allowed')
322
+ }
323
+ break
324
+ }
325
+ }
326
+
327
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
328
+
329
+ function checkPortInUse(port) {
330
+ return new Promise((resolve) => {
331
+ const s = createServer()
332
+ s.once('error', (err) => { if (err.code === 'EADDRINUSE') resolve(true); else resolve(false) })
333
+ s.once('listening', () => { s.close(); resolve(false) })
334
+ s.listen(port)
335
+ })
336
+ }
337
+
338
+ function openBrowser(url) {
339
+ const cmd = process.platform === 'darwin' ? 'open'
340
+ : process.platform === 'win32' ? 'start'
341
+ : 'xdg-open'
342
+ exec(`${cmd} "${url}"`, (err) => {
343
+ if (err) console.log(` 💡 Open manually: ${url}`)
344
+ })
345
+ }
346
+
347
+ // ─── Exports ─────────────────────────────────────────────────────────────────
348
+
349
+ export async function startWebServer(port = 3333, { open = true } = {}) {
350
+ const alreadyRunning = await checkPortInUse(port)
351
+ const url = `http://localhost:${port}`
352
+
353
+ if (alreadyRunning) {
354
+ console.log()
355
+ console.log(` ⚡ free-coding-models Web Dashboard already running`)
356
+ console.log(` 🌐 ${url}`)
357
+ console.log()
358
+ if (open) openBrowser(url)
359
+ return null
360
+ }
361
+
362
+ const server = createServer(handleRequest)
363
+
364
+ server.listen(port, () => {
365
+ console.log()
366
+ console.log(` ⚡ free-coding-models Web Dashboard`)
367
+ console.log(` 🌐 ${url}`)
368
+ console.log(` 📊 Monitoring ${results.filter(r => !r.cliOnly).length} models across ${Object.keys(sources).length} providers`)
369
+ console.log()
370
+ console.log(` Press Ctrl+C to stop`)
371
+ console.log()
372
+ if (open) openBrowser(url)
373
+ })
374
+
375
+ async function schedulePingLoop() {
376
+ await pingAllModels()
377
+ setTimeout(schedulePingLoop, 10_000)
378
+ }
379
+ schedulePingLoop()
380
+
381
+ return server
382
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @file web/src/App.jsx
3
+ * @description Root application component — orchestrates all views, layout, SSE connection, and global state.
4
+ * 📖 Manages current view (dashboard/settings/analytics), theme toggle, search, filters,
5
+ * selected model for detail panel, export modal, and toast notifications.
6
+ * Uses useSSE for live data, useFilter for model filtering/sorting, useTheme for dark/light.
7
+ * @functions App → root component with all state and layout composition
8
+ */
9
+ import { useState, useCallback, useEffect } from 'react'
10
+ import { useSSE } from './hooks/useSSE.js'
11
+ import { useFilter } from './hooks/useFilter.js'
12
+ import { useTheme } from './hooks/useTheme.js'
13
+ import Header from './components/layout/Header.jsx'
14
+ import Sidebar from './components/layout/Sidebar.jsx'
15
+ import Footer from './components/layout/Footer.jsx'
16
+ import StatsBar from './components/dashboard/StatsBar.jsx'
17
+ import FilterBar from './components/dashboard/FilterBar.jsx'
18
+ import ModelTable from './components/dashboard/ModelTable.jsx'
19
+ import DetailPanel from './components/dashboard/DetailPanel.jsx'
20
+ import ExportModal from './components/dashboard/ExportModal.jsx'
21
+ import SettingsView from './components/settings/SettingsView.jsx'
22
+ import AnalyticsView from './components/analytics/AnalyticsView.jsx'
23
+ import ToastContainer from './components/atoms/ToastContainer.jsx'
24
+
25
+ let toastIdCounter = 0
26
+
27
+ export default function App() {
28
+ const { models, connected } = useSSE('/api/events')
29
+ const { theme, toggle: toggleTheme } = useTheme()
30
+ const [currentView, setCurrentView] = useState('dashboard')
31
+ const [selectedModel, setSelectedModel] = useState(null)
32
+ const [exportOpen, setExportOpen] = useState(false)
33
+ const [toasts, setToasts] = useState([])
34
+
35
+ const {
36
+ filtered,
37
+ filterTier, setFilterTier,
38
+ filterStatus, setFilterStatus,
39
+ filterProvider, setFilterProvider,
40
+ searchQuery, setSearchQuery,
41
+ sortColumn, sortDirection, toggleSort,
42
+ } = useFilter(models)
43
+
44
+ const providers = (() => {
45
+ const map = {}
46
+ models.forEach((m) => {
47
+ if (!map[m.providerKey]) map[m.providerKey] = { key: m.providerKey, name: m.origin, count: 0 }
48
+ map[m.providerKey].count++
49
+ })
50
+ return Object.values(map).sort((a, b) => a.name.localeCompare(b.name))
51
+ })()
52
+
53
+ const addToast = useCallback((message, type = 'info') => {
54
+ const id = ++toastIdCounter
55
+ setToasts((prev) => [...prev, { id, message, type }])
56
+ }, [])
57
+
58
+ const dismissToast = useCallback((id) => {
59
+ setToasts((prev) => prev.filter((t) => t.id !== id))
60
+ }, [])
61
+
62
+ const handleSelectModel = useCallback((modelId) => {
63
+ const model = models.find((m) => m.modelId === modelId)
64
+ if (model) setSelectedModel(model)
65
+ }, [models])
66
+
67
+ const handleCloseDetail = useCallback(() => setSelectedModel(null), [])
68
+
69
+ useEffect(() => {
70
+ const handler = (e) => {
71
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
72
+ e.preventDefault()
73
+ if (currentView !== 'dashboard') setCurrentView('dashboard')
74
+ }
75
+ if (e.key === 'Escape') {
76
+ setSelectedModel(null)
77
+ setExportOpen(false)
78
+ }
79
+ }
80
+ window.addEventListener('keydown', handler)
81
+ return () => window.removeEventListener('keydown', handler)
82
+ }, [currentView])
83
+
84
+ return (
85
+ <>
86
+ <Sidebar
87
+ currentView={currentView}
88
+ onNavigate={setCurrentView}
89
+ onToggleTheme={toggleTheme}
90
+ />
91
+
92
+ <div className="app-content">
93
+ {currentView === 'dashboard' && (
94
+ <div className="view">
95
+ <Header
96
+ searchQuery={searchQuery}
97
+ onSearchChange={setSearchQuery}
98
+ onToggleTheme={toggleTheme}
99
+ onOpenSettings={() => setCurrentView('settings')}
100
+ onOpenExport={() => setExportOpen(true)}
101
+ />
102
+ <StatsBar models={models} />
103
+ <FilterBar
104
+ filterTier={filterTier}
105
+ setFilterTier={setFilterTier}
106
+ filterStatus={filterStatus}
107
+ setFilterStatus={setFilterStatus}
108
+ filterProvider={filterProvider}
109
+ setFilterProvider={setFilterProvider}
110
+ providers={providers}
111
+ />
112
+ <ModelTable
113
+ filtered={filtered}
114
+ onSelectModel={handleSelectModel}
115
+ />
116
+ </div>
117
+ )}
118
+
119
+ {currentView === 'settings' && (
120
+ <div className="view">
121
+ <SettingsView onToast={addToast} />
122
+ </div>
123
+ )}
124
+
125
+ {currentView === 'analytics' && (
126
+ <div className="view">
127
+ <AnalyticsView models={models} />
128
+ </div>
129
+ )}
130
+
131
+ <Footer />
132
+ </div>
133
+
134
+ <DetailPanel
135
+ model={selectedModel}
136
+ onClose={handleCloseDetail}
137
+ />
138
+
139
+ {exportOpen && (
140
+ <ExportModal
141
+ models={filtered}
142
+ onClose={() => setExportOpen(false)}
143
+ onToast={addToast}
144
+ />
145
+ )}
146
+
147
+ <ToastContainer toasts={toasts} dismissToast={dismissToast} />
148
+ </>
149
+ )
150
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * @file web/src/components/analytics/AnalyticsView.jsx
3
+ * @description Analytics dashboard page showing provider health, fastest models leaderboard, and tier distribution.
4
+ * 📖 Purely derived from the `models` SSE data. No API calls needed beyond the live model feed.
5
+ * @functions AnalyticsView → renders the three analytics cards
6
+ */
7
+ import { useMemo } from 'react'
8
+ import TierBadge from '../atoms/TierBadge.jsx'
9
+ import styles from './AnalyticsView.module.css'
10
+
11
+ const TIER_COLORS = {
12
+ 'S+': '#ffd700', S: '#ff8c00', 'A+': '#00c8ff', A: '#3ddc84',
13
+ 'A-': '#7ecf7e', 'B+': '#a8a8c8', B: '#808098', C: '#606078',
14
+ }
15
+ const TIERS = ['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
16
+
17
+ export default function AnalyticsView({ models }) {
18
+ const providerHealth = useMemo(() => {
19
+ const map = {}
20
+ models.forEach((m) => {
21
+ if (!map[m.origin]) map[m.origin] = { total: 0, online: 0, key: m.providerKey }
22
+ map[m.origin].total++
23
+ if (m.status === 'up') map[m.origin].online++
24
+ })
25
+ return Object.entries(map).sort((a, b) => (b[1].online / b[1].total) - (a[1].online / a[1].total))
26
+ }, [models])
27
+
28
+ const leaderboard = useMemo(() => {
29
+ const online = models.filter((m) => m.status === 'up' && m.avg !== Infinity && m.avg < 99000)
30
+ return [...online].sort((a, b) => a.avg - b.avg).slice(0, 10)
31
+ }, [models])
32
+
33
+ const tierCounts = useMemo(() => {
34
+ const counts = {}
35
+ models.forEach((m) => { counts[m.tier] = (counts[m.tier] || 0) + 1 })
36
+ const maxCount = Math.max(...Object.values(counts), 1)
37
+ return TIERS.map((t) => ({ tier: t, count: counts[t] || 0, pct: ((counts[t] || 0) / maxCount) * 100 }))
38
+ }, [models])
39
+
40
+ return (
41
+ <div className={styles.page}>
42
+ <div className={styles.pageHeader}>
43
+ <h1 className={styles.pageTitle}>📊 Analytics</h1>
44
+ <p className={styles.pageSubtitle}>Real-time insights across all providers and models</p>
45
+ </div>
46
+
47
+ <div className={styles.grid}>
48
+ <div className={`${styles.card} ${styles.cardWide}`}>
49
+ <h3 className={styles.cardTitle}>Provider Health Overview</h3>
50
+ <div className={styles.cardBody}>
51
+ {providerHealth.length === 0 ? (
52
+ <div className={styles.empty}>Waiting for data...</div>
53
+ ) : (
54
+ providerHealth.map(([name, data]) => {
55
+ const pct = data.total > 0 ? Math.round((data.online / data.total) * 100) : 0
56
+ const pctCls = pct > 70 ? styles.pctFast : pct > 30 ? styles.pctMedium : styles.pctSlow
57
+ return (
58
+ <div key={name} className={styles.healthItem}>
59
+ <span className={styles.healthName}>{name}</span>
60
+ <div className={styles.healthBar}>
61
+ <div className={styles.healthFill} style={{ width: `${pct}%` }} />
62
+ </div>
63
+ <span className={`${styles.healthPct} ${pctCls}`}>{pct}%</span>
64
+ </div>
65
+ )
66
+ })
67
+ )}
68
+ </div>
69
+ </div>
70
+
71
+ <div className={styles.card}>
72
+ <h3 className={styles.cardTitle}>🏆 Fastest Models</h3>
73
+ <div className={styles.cardBody}>
74
+ {leaderboard.length === 0 ? (
75
+ <div className={styles.empty}>Waiting for ping data...</div>
76
+ ) : (
77
+ leaderboard.map((m, i) => {
78
+ const rankCls = i < 3 ? styles[`rank${i + 1}`] : ''
79
+ const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : (i + 1)
80
+ return (
81
+ <div key={m.modelId} className={styles.leaderItem}>
82
+ <div className={`${styles.leaderRank} ${rankCls}`}>{medal}</div>
83
+ <span className={styles.leaderName}>{m.label}</span>
84
+ <span className={styles.leaderLatency}>{m.avg}ms</span>
85
+ </div>
86
+ )
87
+ })
88
+ )}
89
+ </div>
90
+ </div>
91
+
92
+ <div className={styles.card}>
93
+ <h3 className={styles.cardTitle}>Tier Distribution</h3>
94
+ <div className={styles.cardBody}>
95
+ {tierCounts.map(({ tier, count, pct }) => (
96
+ <div key={tier} className={styles.tierItem}>
97
+ <div className={styles.tierBadge}><TierBadge tier={tier} /></div>
98
+ <div className={styles.tierBar}>
99
+ <div className={styles.tierFill} style={{ width: `${pct}%`, background: TIER_COLORS[tier] }} />
100
+ </div>
101
+ <span className={styles.tierCount}>{count}</span>
102
+ </div>
103
+ ))}
104
+ </div>
105
+ </div>
106
+ </div>
107
+ </div>
108
+ )
109
+ }