free-coding-models 0.3.37 → 0.3.41
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 +5 -1800
- package/README.md +10 -1
- package/bin/free-coding-models.js +8 -0
- package/package.json +13 -3
- package/src/app.js +30 -0
- package/src/cli-help.js +2 -0
- package/src/command-palette.js +3 -0
- package/src/config.js +7 -0
- package/src/endpoint-installer.js +1 -1
- package/src/key-handler.js +27 -1
- package/src/overlays.js +11 -1
- package/src/shell-env.js +393 -0
- package/src/tool-bootstrap.js +41 -0
- package/src/tool-launchers.js +166 -1
- package/src/tool-metadata.js +12 -0
- package/src/utils.js +12 -0
- package/web/app.legacy.js +900 -0
- package/web/index.html +20 -0
- package/web/server.js +443 -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,443 @@
|
|
|
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/health → Lightweight dashboard health probe
|
|
15
|
+
* GET /api/config → Current config (sanitized — masked keys)
|
|
16
|
+
* GET /api/key/:prov → Reveal a provider's full API key
|
|
17
|
+
* GET /api/events → SSE stream of live ping results
|
|
18
|
+
* POST /api/settings → Update API keys / provider toggles
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createServer } from 'node:http'
|
|
22
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
23
|
+
import { join, dirname, extname } from 'node:path'
|
|
24
|
+
import { fileURLToPath } from 'node:url'
|
|
25
|
+
import { exec } from 'node:child_process'
|
|
26
|
+
|
|
27
|
+
import { sources, MODELS } from '../sources.js'
|
|
28
|
+
import { loadConfig, getApiKey, saveConfig, isProviderEnabled } from '../src/config.js'
|
|
29
|
+
import { ping } from '../src/ping.js'
|
|
30
|
+
import {
|
|
31
|
+
getAvg, getVerdict, getUptime, getP95, getJitter,
|
|
32
|
+
getStabilityScore, TIER_ORDER
|
|
33
|
+
} from '../src/utils.js'
|
|
34
|
+
|
|
35
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
36
|
+
const SERVER_SIGNATURE = 'free-coding-models-web'
|
|
37
|
+
|
|
38
|
+
// ─── State ───────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
let config = loadConfig()
|
|
41
|
+
|
|
42
|
+
// Build results array from MODELS (same shape as the TUI)
|
|
43
|
+
const results = MODELS.map(([modelId, label, tier, sweScore, ctx, providerKey], idx) => ({
|
|
44
|
+
idx: idx + 1,
|
|
45
|
+
modelId,
|
|
46
|
+
label,
|
|
47
|
+
tier,
|
|
48
|
+
sweScore,
|
|
49
|
+
ctx,
|
|
50
|
+
providerKey,
|
|
51
|
+
status: 'pending',
|
|
52
|
+
pings: [],
|
|
53
|
+
httpCode: null,
|
|
54
|
+
origin: sources[providerKey]?.name || providerKey,
|
|
55
|
+
url: sources[providerKey]?.url || null,
|
|
56
|
+
cliOnly: sources[providerKey]?.cliOnly || false,
|
|
57
|
+
zenOnly: sources[providerKey]?.zenOnly || false,
|
|
58
|
+
}))
|
|
59
|
+
|
|
60
|
+
// SSE clients
|
|
61
|
+
const sseClients = new Set()
|
|
62
|
+
|
|
63
|
+
// ─── Ping Loop ───────────────────────────────────────────────────────────────
|
|
64
|
+
// Uses recursive setTimeout (not setInterval) to prevent overlapping rounds.
|
|
65
|
+
// Each new round starts only after the previous one completes.
|
|
66
|
+
|
|
67
|
+
let pingRound = 0
|
|
68
|
+
let pingLoopRunning = false
|
|
69
|
+
|
|
70
|
+
async function pingAllModels() {
|
|
71
|
+
if (pingLoopRunning) return // guard against overlapping calls
|
|
72
|
+
pingLoopRunning = true
|
|
73
|
+
pingRound++
|
|
74
|
+
const batchSize = 30
|
|
75
|
+
// P2 fix: honor provider enabled flags — skip disabled providers
|
|
76
|
+
const modelsToPing = results.filter(r =>
|
|
77
|
+
!r.cliOnly && r.url && isProviderEnabled(config, r.providerKey)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
for (let i = 0; i < modelsToPing.length; i += batchSize) {
|
|
81
|
+
const batch = modelsToPing.slice(i, i + batchSize)
|
|
82
|
+
const promises = batch.map(async (r) => {
|
|
83
|
+
const apiKey = getApiKey(config, r.providerKey)
|
|
84
|
+
try {
|
|
85
|
+
const result = await ping(apiKey, r.modelId, r.providerKey, r.url)
|
|
86
|
+
r.httpCode = result.code
|
|
87
|
+
if (result.code === '200') {
|
|
88
|
+
r.status = 'up'
|
|
89
|
+
r.pings.push({ ms: result.ms, code: result.code })
|
|
90
|
+
} else if (result.code === '401') {
|
|
91
|
+
r.status = 'up'
|
|
92
|
+
r.pings.push({ ms: result.ms, code: result.code })
|
|
93
|
+
} else if (result.code === '429') {
|
|
94
|
+
r.status = 'up'
|
|
95
|
+
r.pings.push({ ms: result.ms, code: result.code })
|
|
96
|
+
} else if (result.code === '000') {
|
|
97
|
+
r.status = 'timeout'
|
|
98
|
+
} else {
|
|
99
|
+
r.status = 'down'
|
|
100
|
+
r.pings.push({ ms: result.ms, code: result.code })
|
|
101
|
+
}
|
|
102
|
+
// Keep only last 60 pings
|
|
103
|
+
if (r.pings.length > 60) r.pings = r.pings.slice(-60)
|
|
104
|
+
} catch {
|
|
105
|
+
r.status = 'timeout'
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
await Promise.all(promises)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Broadcast update to all SSE clients
|
|
112
|
+
broadcastUpdate()
|
|
113
|
+
pingLoopRunning = false
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function broadcastUpdate() {
|
|
117
|
+
const data = JSON.stringify(getModelsPayload())
|
|
118
|
+
for (const client of sseClients) {
|
|
119
|
+
try {
|
|
120
|
+
client.write(`data: ${data}\n\n`)
|
|
121
|
+
} catch {
|
|
122
|
+
sseClients.delete(client)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getModelsPayload() {
|
|
128
|
+
return results.map(r => ({
|
|
129
|
+
idx: r.idx,
|
|
130
|
+
modelId: r.modelId,
|
|
131
|
+
label: r.label,
|
|
132
|
+
tier: r.tier,
|
|
133
|
+
sweScore: r.sweScore,
|
|
134
|
+
ctx: r.ctx,
|
|
135
|
+
providerKey: r.providerKey,
|
|
136
|
+
origin: r.origin,
|
|
137
|
+
status: r.status,
|
|
138
|
+
httpCode: r.httpCode,
|
|
139
|
+
cliOnly: r.cliOnly,
|
|
140
|
+
zenOnly: r.zenOnly,
|
|
141
|
+
avg: getAvg(r),
|
|
142
|
+
verdict: getVerdict(r),
|
|
143
|
+
uptime: getUptime(r),
|
|
144
|
+
p95: getP95(r),
|
|
145
|
+
jitter: getJitter(r),
|
|
146
|
+
stability: getStabilityScore(r),
|
|
147
|
+
latestPing: r.pings.length > 0 ? r.pings[r.pings.length - 1].ms : null,
|
|
148
|
+
latestCode: r.pings.length > 0 ? r.pings[r.pings.length - 1].code : null,
|
|
149
|
+
pingHistory: r.pings.slice(-20).map(p => ({ ms: p.ms, code: p.code })),
|
|
150
|
+
pingCount: r.pings.length,
|
|
151
|
+
hasApiKey: !!getApiKey(config, r.providerKey),
|
|
152
|
+
}))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function getConfigPayload() {
|
|
156
|
+
// Sanitize — show which providers have keys, but not the actual keys
|
|
157
|
+
const providers = {}
|
|
158
|
+
for (const [key, src] of Object.entries(sources)) {
|
|
159
|
+
const rawKey = getApiKey(config, key)
|
|
160
|
+
providers[key] = {
|
|
161
|
+
name: src.name,
|
|
162
|
+
hasKey: !!rawKey,
|
|
163
|
+
maskedKey: rawKey ? maskApiKey(rawKey) : null,
|
|
164
|
+
enabled: isProviderEnabled(config, key),
|
|
165
|
+
modelCount: src.models?.length || 0,
|
|
166
|
+
cliOnly: src.cliOnly || false,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { providers, totalModels: MODELS.length }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function maskApiKey(key) {
|
|
173
|
+
if (!key || typeof key !== 'string') return ''
|
|
174
|
+
if (key.length <= 8) return '••••••••'
|
|
175
|
+
return '••••••••' + key.slice(-4)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─── HTTP Server ─────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
const MIME_TYPES = {
|
|
181
|
+
'.html': 'text/html; charset=utf-8',
|
|
182
|
+
'.css': 'text/css; charset=utf-8',
|
|
183
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
184
|
+
'.json': 'application/json; charset=utf-8',
|
|
185
|
+
'.svg': 'image/svg+xml',
|
|
186
|
+
'.png': 'image/png',
|
|
187
|
+
'.ico': 'image/x-icon',
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function serveFile(res, filename, contentType) {
|
|
191
|
+
try {
|
|
192
|
+
const content = readFileSync(join(__dirname, filename), 'utf8')
|
|
193
|
+
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' })
|
|
194
|
+
res.end(content)
|
|
195
|
+
} catch {
|
|
196
|
+
res.writeHead(404)
|
|
197
|
+
res.end('Not Found')
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function serveDistFile(res, pathname) {
|
|
202
|
+
const filePath = join(__dirname, 'dist', pathname === '/' ? 'index.html' : pathname)
|
|
203
|
+
if (!existsSync(filePath)) {
|
|
204
|
+
serveFile(res, 'dist/index.html', 'text/html; charset=utf-8')
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
const ext = extname(filePath)
|
|
208
|
+
const ct = MIME_TYPES[ext] || 'application/octet-stream'
|
|
209
|
+
try {
|
|
210
|
+
const content = readFileSync(filePath)
|
|
211
|
+
res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable' })
|
|
212
|
+
res.end(content)
|
|
213
|
+
} catch {
|
|
214
|
+
res.writeHead(404)
|
|
215
|
+
res.end('Not Found')
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function handleRequest(req, res) {
|
|
220
|
+
res.setHeader('X-FCM-Server', SERVER_SIGNATURE)
|
|
221
|
+
|
|
222
|
+
// CORS for local dev
|
|
223
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
224
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
225
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
226
|
+
|
|
227
|
+
if (req.method === 'OPTIONS') {
|
|
228
|
+
res.writeHead(204)
|
|
229
|
+
res.end()
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
234
|
+
|
|
235
|
+
// ─── API: Reveal full key for a provider ───
|
|
236
|
+
const keyMatch = url.pathname.match(/^\/api\/key\/(.+)$/)
|
|
237
|
+
if (keyMatch) {
|
|
238
|
+
const providerKey = decodeURIComponent(keyMatch[1])
|
|
239
|
+
const rawKey = getApiKey(config, providerKey)
|
|
240
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
241
|
+
res.end(JSON.stringify({ key: rawKey || null }))
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
switch (url.pathname) {
|
|
246
|
+
case '/':
|
|
247
|
+
serveDistFile(res, '/')
|
|
248
|
+
break
|
|
249
|
+
|
|
250
|
+
case '/styles.css':
|
|
251
|
+
case '/app.js':
|
|
252
|
+
serveDistFile(res, url.pathname)
|
|
253
|
+
break
|
|
254
|
+
|
|
255
|
+
default:
|
|
256
|
+
if (url.pathname.startsWith('/assets/') || url.pathname.endsWith('.js') || url.pathname.endsWith('.css')) {
|
|
257
|
+
serveDistFile(res, url.pathname)
|
|
258
|
+
break
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!url.pathname.startsWith('/api/')) {
|
|
262
|
+
res.writeHead(404)
|
|
263
|
+
res.end('Not Found')
|
|
264
|
+
break
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
case '/api/models':
|
|
268
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
269
|
+
res.end(JSON.stringify(getModelsPayload()))
|
|
270
|
+
break
|
|
271
|
+
|
|
272
|
+
case '/api/health':
|
|
273
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
274
|
+
res.end(JSON.stringify({ ok: true, app: SERVER_SIGNATURE }))
|
|
275
|
+
break
|
|
276
|
+
|
|
277
|
+
case '/api/config':
|
|
278
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
279
|
+
res.end(JSON.stringify(getConfigPayload()))
|
|
280
|
+
break
|
|
281
|
+
|
|
282
|
+
case '/api/events':
|
|
283
|
+
// SSE endpoint
|
|
284
|
+
res.writeHead(200, {
|
|
285
|
+
'Content-Type': 'text/event-stream',
|
|
286
|
+
'Cache-Control': 'no-cache',
|
|
287
|
+
'Connection': 'keep-alive',
|
|
288
|
+
})
|
|
289
|
+
res.write(`data: ${JSON.stringify(getModelsPayload())}\n\n`)
|
|
290
|
+
sseClients.add(res)
|
|
291
|
+
req.on('close', () => sseClients.delete(res))
|
|
292
|
+
break
|
|
293
|
+
|
|
294
|
+
case '/api/settings':
|
|
295
|
+
if (req.method === 'POST') {
|
|
296
|
+
let body = ''
|
|
297
|
+
req.on('data', chunk => body += chunk)
|
|
298
|
+
req.on('end', () => {
|
|
299
|
+
try {
|
|
300
|
+
const settings = JSON.parse(body)
|
|
301
|
+
if (settings.apiKeys) {
|
|
302
|
+
for (const [key, value] of Object.entries(settings.apiKeys)) {
|
|
303
|
+
if (value) config.apiKeys[key] = value
|
|
304
|
+
else delete config.apiKeys[key]
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (settings.providers) {
|
|
308
|
+
for (const [key, value] of Object.entries(settings.providers)) {
|
|
309
|
+
if (!config.providers[key]) config.providers[key] = {}
|
|
310
|
+
config.providers[key].enabled = value.enabled !== false
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// P2 fix: catch saveConfig failures and report to client
|
|
314
|
+
try {
|
|
315
|
+
saveConfig(config)
|
|
316
|
+
} catch (saveErr) {
|
|
317
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
318
|
+
res.end(JSON.stringify({ success: false, error: 'Failed to save config: ' + saveErr.message }))
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
322
|
+
res.end(JSON.stringify({ success: true }))
|
|
323
|
+
} catch (err) {
|
|
324
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
325
|
+
res.end(JSON.stringify({ error: err.message }))
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
} else {
|
|
329
|
+
res.writeHead(405)
|
|
330
|
+
res.end('Method Not Allowed')
|
|
331
|
+
}
|
|
332
|
+
break
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
function checkPortInUse(port) {
|
|
339
|
+
return new Promise((resolve) => {
|
|
340
|
+
const s = createServer()
|
|
341
|
+
s.once('error', (err) => { if (err.code === 'EADDRINUSE') resolve(true); else resolve(false) })
|
|
342
|
+
s.once('listening', () => { s.close(); resolve(false) })
|
|
343
|
+
s.listen(port)
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export async function inspectExistingWebServer(port) {
|
|
348
|
+
const inUse = await checkPortInUse(port)
|
|
349
|
+
if (!inUse) return { inUse: false, isFcm: false }
|
|
350
|
+
|
|
351
|
+
const controller = new AbortController()
|
|
352
|
+
const timeout = setTimeout(() => controller.abort(), 750)
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
// 📖 Probe a tiny health route so we only reuse a port when the running
|
|
356
|
+
// 📖 process is actually the free-coding-models dashboard, not any random app.
|
|
357
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
|
358
|
+
signal: controller.signal,
|
|
359
|
+
headers: { Accept: 'application/json' },
|
|
360
|
+
})
|
|
361
|
+
const payload = await response.json().catch(() => null)
|
|
362
|
+
const signature = response.headers.get('x-fcm-server')
|
|
363
|
+
return {
|
|
364
|
+
inUse: true,
|
|
365
|
+
isFcm: signature === SERVER_SIGNATURE || payload?.app === SERVER_SIGNATURE,
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
return { inUse: true, isFcm: false }
|
|
369
|
+
} finally {
|
|
370
|
+
clearTimeout(timeout)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export async function findAvailablePort(startPort, maxAttempts = 20) {
|
|
375
|
+
for (let port = startPort; port < startPort + maxAttempts; port++) {
|
|
376
|
+
if (!(await checkPortInUse(port))) return port
|
|
377
|
+
}
|
|
378
|
+
throw new Error(`No free local port found between ${startPort} and ${startPort + maxAttempts - 1}`)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function openBrowser(url) {
|
|
382
|
+
const cmd = process.platform === 'darwin' ? 'open'
|
|
383
|
+
: process.platform === 'win32' ? 'start'
|
|
384
|
+
: 'xdg-open'
|
|
385
|
+
exec(`${cmd} "${url}"`, (err) => {
|
|
386
|
+
if (err) console.log(` 💡 Open manually: ${url}`)
|
|
387
|
+
})
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
export async function startWebServer(port = 3333, { open = true, startPingLoop = true } = {}) {
|
|
393
|
+
const portStatus = await inspectExistingWebServer(port)
|
|
394
|
+
|
|
395
|
+
if (portStatus.inUse && portStatus.isFcm) {
|
|
396
|
+
const url = `http://localhost:${port}`
|
|
397
|
+
|
|
398
|
+
console.log()
|
|
399
|
+
console.log(` ⚡ free-coding-models Web Dashboard already running`)
|
|
400
|
+
console.log(` 🌐 ${url}`)
|
|
401
|
+
console.log()
|
|
402
|
+
if (open) openBrowser(url)
|
|
403
|
+
return null
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
let resolvedPort = port
|
|
407
|
+
if (portStatus.inUse && !portStatus.isFcm) {
|
|
408
|
+
resolvedPort = await findAvailablePort(port + 1)
|
|
409
|
+
console.log()
|
|
410
|
+
console.log(` ⚠️ Port ${port} is already in use by another local app`)
|
|
411
|
+
console.log(` ↪ Starting free-coding-models Web Dashboard on port ${resolvedPort} instead`)
|
|
412
|
+
console.log()
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const url = `http://localhost:${resolvedPort}`
|
|
416
|
+
|
|
417
|
+
const server = createServer(handleRequest)
|
|
418
|
+
let pingLoopTimer = null
|
|
419
|
+
|
|
420
|
+
server.listen(resolvedPort, () => {
|
|
421
|
+
console.log()
|
|
422
|
+
console.log(` ⚡ free-coding-models Web Dashboard`)
|
|
423
|
+
console.log(` 🌐 ${url}`)
|
|
424
|
+
console.log(` 📊 Monitoring ${results.filter(r => !r.cliOnly).length} models across ${Object.keys(sources).length} providers`)
|
|
425
|
+
console.log()
|
|
426
|
+
console.log(` Press Ctrl+C to stop`)
|
|
427
|
+
console.log()
|
|
428
|
+
if (open) openBrowser(url)
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
async function schedulePingLoop() {
|
|
432
|
+
if (!server.listening) return
|
|
433
|
+
await pingAllModels()
|
|
434
|
+
pingLoopTimer = setTimeout(schedulePingLoop, 10_000)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (startPingLoop) schedulePingLoop()
|
|
438
|
+
server.on('close', () => {
|
|
439
|
+
if (pingLoopTimer) clearTimeout(pingLoopTimer)
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
return server
|
|
443
|
+
}
|
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
|
+
}
|