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.
- package/CHANGELOG.md +10 -1794
- package/README.md +4 -1
- package/bin/free-coding-models.js +8 -0
- package/package.json +13 -3
- package/src/app.js +3 -0
- package/src/cli-help.js +2 -0
- package/src/command-palette.js +3 -0
- package/src/endpoint-installer.js +1 -1
- package/src/tool-bootstrap.js +34 -0
- package/src/tool-launchers.js +137 -1
- package/src/tool-metadata.js +9 -0
- package/src/utils.js +10 -0
- package/web/app.legacy.js +900 -0
- package/web/index.html +20 -0
- package/web/server.js +382 -0
- package/web/src/App.jsx +150 -0
- package/web/src/components/analytics/AnalyticsView.jsx +109 -0
- package/web/src/components/analytics/AnalyticsView.module.css +186 -0
- package/web/src/components/atoms/Sparkline.jsx +44 -0
- package/web/src/components/atoms/StabilityCell.jsx +18 -0
- package/web/src/components/atoms/StabilityCell.module.css +8 -0
- package/web/src/components/atoms/StatusDot.jsx +10 -0
- package/web/src/components/atoms/StatusDot.module.css +17 -0
- package/web/src/components/atoms/TierBadge.jsx +10 -0
- package/web/src/components/atoms/TierBadge.module.css +18 -0
- package/web/src/components/atoms/Toast.jsx +25 -0
- package/web/src/components/atoms/Toast.module.css +35 -0
- package/web/src/components/atoms/ToastContainer.jsx +16 -0
- package/web/src/components/atoms/ToastContainer.module.css +10 -0
- package/web/src/components/atoms/VerdictBadge.jsx +13 -0
- package/web/src/components/atoms/VerdictBadge.module.css +19 -0
- package/web/src/components/dashboard/DetailPanel.jsx +131 -0
- package/web/src/components/dashboard/DetailPanel.module.css +99 -0
- package/web/src/components/dashboard/ExportModal.jsx +79 -0
- package/web/src/components/dashboard/ExportModal.module.css +99 -0
- package/web/src/components/dashboard/FilterBar.jsx +73 -0
- package/web/src/components/dashboard/FilterBar.module.css +43 -0
- package/web/src/components/dashboard/ModelTable.jsx +86 -0
- package/web/src/components/dashboard/ModelTable.module.css +46 -0
- package/web/src/components/dashboard/StatsBar.jsx +40 -0
- package/web/src/components/dashboard/StatsBar.module.css +28 -0
- package/web/src/components/layout/Footer.jsx +19 -0
- package/web/src/components/layout/Footer.module.css +10 -0
- package/web/src/components/layout/Header.jsx +38 -0
- package/web/src/components/layout/Header.module.css +73 -0
- package/web/src/components/layout/Sidebar.jsx +41 -0
- package/web/src/components/layout/Sidebar.module.css +76 -0
- package/web/src/components/settings/SettingsView.jsx +264 -0
- package/web/src/components/settings/SettingsView.module.css +377 -0
- package/web/src/global.css +199 -0
- package/web/src/hooks/useFilter.js +83 -0
- package/web/src/hooks/useSSE.js +49 -0
- package/web/src/hooks/useTheme.js +27 -0
- package/web/src/main.jsx +15 -0
- package/web/src/utils/download.js +15 -0
- package/web/src/utils/format.js +42 -0
- package/web/src/utils/ranks.js +37 -0
- 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
|
+
}
|
package/web/src/App.jsx
ADDED
|
@@ -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
|
+
}
|