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
@@ -0,0 +1,900 @@
1
+ /**
2
+ * @file web/app.js
3
+ * @description Client-side JavaScript for the free-coding-models Web Dashboard V2.
4
+ *
5
+ * Features:
6
+ * - Real-time SSE model updates
7
+ * - Sidebar navigation (Dashboard / Settings / Analytics)
8
+ * - Full API key management (add, edit, delete, reveal, copy)
9
+ * - Toast notification system
10
+ * - Export (JSON, CSV, clipboard)
11
+ * - Analytics view with provider health, leaderboard, tier distribution
12
+ */
13
+
14
+ // ─── State ───────────────────────────────────────────────────────────────────
15
+
16
+ let models = []
17
+ let sortColumn = 'avg'
18
+ let sortDirection = 'asc'
19
+ let filterTier = 'all'
20
+ let filterStatus = 'all'
21
+ let filterProvider = 'all'
22
+ let searchQuery = ''
23
+ let selectedModelId = null
24
+ let eventSource = null
25
+ let updateCount = 0
26
+ let configData = null
27
+ let revealedKeys = new Set()
28
+ let currentView = 'dashboard'
29
+
30
+ // ─── DOM References ───────────────────────────────────────────────────────────
31
+
32
+ const $ = (sel) => document.querySelector(sel)
33
+ const $$ = (sel) => document.querySelectorAll(sel)
34
+
35
+ const tableBody = $('#table-body')
36
+ const searchInput = $('#search-input')
37
+ const themeToggle = $('#theme-toggle')
38
+ const settingsBtn = $('#settings-btn')
39
+ const detailPanel = $('#detail-panel')
40
+ const detailClose = $('#detail-close')
41
+ const detailTitle = $('#detail-title')
42
+ const detailBody = $('#detail-body')
43
+ const providerFilter = $('#provider-filter')
44
+ const toastContainer = $('#toast-container')
45
+
46
+ // ─── Toast Notification System ───────────────────────────────────────────────
47
+
48
+ function showToast(message, type = 'info', duration = 3500) {
49
+ const icons = { success: '✅', error: '❌', warning: '⚠️', info: 'ℹ️' }
50
+ const toast = document.createElement('div')
51
+ toast.className = `toast toast--${type}`
52
+ toast.innerHTML = `
53
+ <span class="toast__icon">${icons[type] || '📌'}</span>
54
+ <span class="toast__message">${escapeHtml(message)}</span>
55
+ <button class="toast__close">&times;</button>
56
+ `
57
+ toastContainer.appendChild(toast)
58
+
59
+ const closeBtn = toast.querySelector('.toast__close')
60
+ const dismiss = () => {
61
+ toast.classList.add('toast--exiting')
62
+ setTimeout(() => toast.remove(), 300)
63
+ }
64
+ closeBtn.addEventListener('click', dismiss)
65
+ setTimeout(dismiss, duration)
66
+ }
67
+
68
+ // ─── SSE Connection ──────────────────────────────────────────────────────────
69
+
70
+ function connectSSE() {
71
+ if (eventSource) eventSource.close()
72
+ eventSource = new EventSource('/api/events')
73
+
74
+ eventSource.onmessage = (event) => {
75
+ try {
76
+ const data = JSON.parse(event.data)
77
+ models = data
78
+ updateCount++
79
+ if (currentView === 'dashboard') {
80
+ renderTable()
81
+ updateStats()
82
+ }
83
+ if (currentView === 'analytics') renderAnalytics()
84
+ if (updateCount === 1) populateProviderFilter()
85
+ if (selectedModelId) updateDetailPanel()
86
+ } catch (e) {
87
+ console.error('SSE parse error:', e)
88
+ }
89
+ }
90
+
91
+ eventSource.onerror = () => {
92
+ console.warn('SSE connection lost, reconnecting in 3s...')
93
+ setTimeout(connectSSE, 3000)
94
+ }
95
+ }
96
+
97
+ // ─── View Navigation ─────────────────────────────────────────────────────────
98
+
99
+ function switchView(viewId) {
100
+ currentView = viewId
101
+ $$('.view').forEach(v => v.classList.add('view--hidden'))
102
+ $(`#view-${viewId}`).classList.remove('view--hidden')
103
+ $$('.sidebar__nav-item').forEach(n => n.classList.remove('sidebar__nav-item--active'))
104
+ $(`#nav-${viewId}`)?.classList.add('sidebar__nav-item--active')
105
+
106
+ if (viewId === 'settings') loadSettingsPage()
107
+ if (viewId === 'analytics') renderAnalytics()
108
+ }
109
+
110
+ $$('.sidebar__nav-item[data-view]').forEach(btn => {
111
+ btn.addEventListener('click', () => switchView(btn.dataset.view))
112
+ })
113
+
114
+ settingsBtn.addEventListener('click', () => switchView('settings'))
115
+
116
+ // ─── Rendering ───────────────────────────────────────────────────────────────
117
+
118
+ function getFilteredModels() {
119
+ let filtered = [...models]
120
+
121
+ if (filterTier !== 'all') filtered = filtered.filter(m => m.tier === filterTier)
122
+ if (filterStatus !== 'all') {
123
+ filtered = filtered.filter(m => {
124
+ if (filterStatus === 'up') return m.status === 'up'
125
+ if (filterStatus === 'down') return m.status === 'down' || m.status === 'timeout'
126
+ if (filterStatus === 'pending') return m.status === 'pending'
127
+ return true
128
+ })
129
+ }
130
+ if (filterProvider !== 'all') filtered = filtered.filter(m => m.providerKey === filterProvider)
131
+ if (searchQuery) {
132
+ const q = searchQuery.toLowerCase()
133
+ filtered = filtered.filter(m =>
134
+ m.label.toLowerCase().includes(q) ||
135
+ m.modelId.toLowerCase().includes(q) ||
136
+ m.origin.toLowerCase().includes(q) ||
137
+ m.tier.toLowerCase().includes(q) ||
138
+ (m.verdict || '').toLowerCase().includes(q)
139
+ )
140
+ }
141
+
142
+ filtered.sort((a, b) => {
143
+ let cmp = 0
144
+ const col = sortColumn
145
+ if (col === 'idx') cmp = a.idx - b.idx
146
+ else if (col === 'tier') cmp = tierRank(a.tier) - tierRank(b.tier)
147
+ else if (col === 'label') cmp = a.label.localeCompare(b.label)
148
+ else if (col === 'origin') cmp = a.origin.localeCompare(b.origin)
149
+ else if (col === 'sweScore') cmp = parseSwe(a.sweScore) - parseSwe(b.sweScore)
150
+ else if (col === 'ctx') cmp = parseCtx(a.ctx) - parseCtx(b.ctx)
151
+ else if (col === 'latestPing') cmp = (a.latestPing ?? Infinity) - (b.latestPing ?? Infinity)
152
+ else if (col === 'avg') cmp = (a.avg === Infinity ? 99999 : a.avg) - (b.avg === Infinity ? 99999 : b.avg)
153
+ else if (col === 'stability') cmp = (a.stability ?? -1) - (b.stability ?? -1)
154
+ else if (col === 'verdict') cmp = verdictRank(a.verdict) - verdictRank(b.verdict)
155
+ else if (col === 'uptime') cmp = (a.uptime ?? 0) - (b.uptime ?? 0)
156
+ return sortDirection === 'asc' ? cmp : -cmp
157
+ })
158
+
159
+ return filtered
160
+ }
161
+
162
+ function renderTable() {
163
+ const filtered = getFilteredModels()
164
+
165
+ if (filtered.length === 0) {
166
+ tableBody.innerHTML = `
167
+ <tr class="loading-row">
168
+ <td colspan="12">
169
+ <div class="loading-spinner">
170
+ <span style="font-size: 24px">🔍</span>
171
+ <span>No models match your filters</span>
172
+ </div>
173
+ </td>
174
+ </tr>`
175
+ return
176
+ }
177
+
178
+ const onlineModels = filtered.filter(m => m.status === 'up' && m.avg !== Infinity)
179
+ const sorted = [...onlineModels].sort((a, b) => a.avg - b.avg)
180
+ const top3 = sorted.slice(0, 3).map(m => m.modelId)
181
+
182
+ const html = filtered.map((m, i) => {
183
+ const rankClass = top3.indexOf(m.modelId) === 0 ? 'rank-1' :
184
+ top3.indexOf(m.modelId) === 1 ? 'rank-2' :
185
+ top3.indexOf(m.modelId) === 2 ? 'rank-3' : ''
186
+ const medal = top3.indexOf(m.modelId) === 0 ? '🥇' :
187
+ top3.indexOf(m.modelId) === 1 ? '🥈' :
188
+ top3.indexOf(m.modelId) === 2 ? '🥉' : ''
189
+
190
+ return `<tr class="${rankClass}" data-model-id="${m.modelId}" data-provider="${m.providerKey}">
191
+ <td class="td--rank">${medal || (i + 1)}</td>
192
+ <td>${tierBadge(m.tier)}</td>
193
+ <td>
194
+ <div class="model-name">
195
+ <span class="status-dot status-dot--${m.status}"></span>${escapeHtml(m.label)}
196
+ ${!m.hasApiKey && !m.cliOnly ? '<span class="no-key-badge">🔑 NO KEY</span>' : ''}
197
+ </div>
198
+ <div class="model-id">${escapeHtml(m.modelId)}</div>
199
+ </td>
200
+ <td><span class="provider-pill">${escapeHtml(m.origin)}</span></td>
201
+ <td class="swe-score ${sweClass(m.sweScore)}">${m.sweScore || '—'}</td>
202
+ <td class="ctx-value">${m.ctx || '—'}</td>
203
+ <td class="ping-value ${pingClass(m.latestPing)}">${formatPing(m.latestPing, m.latestCode)}</td>
204
+ <td class="ping-value ${pingClass(m.avg)}">${formatAvg(m.avg)}</td>
205
+ <td class="td--stability">${stabilityCell(m.stability)}</td>
206
+ <td>${verdictBadge(m.verdict, m.httpCode)}</td>
207
+ <td class="td--uptime"><span class="uptime-value">${m.uptime > 0 ? m.uptime + '%' : '—'}</span></td>
208
+ <td class="td--sparkline">${sparkline(m.pingHistory)}</td>
209
+ </tr>`
210
+ }).join('')
211
+
212
+ tableBody.innerHTML = html
213
+
214
+ tableBody.querySelectorAll('tr[data-model-id]').forEach(row => {
215
+ row.addEventListener('click', () => {
216
+ selectedModelId = row.dataset.modelId
217
+ showDetailPanel(selectedModelId)
218
+ })
219
+ })
220
+ }
221
+
222
+ // ─── Cell Renderers ──────────────────────────────────────────────────────────
223
+
224
+ function tierBadge(tier) {
225
+ const cls = tier.replace('+', 'plus').replace('-', 'minus').toLowerCase()
226
+ return `<span class="tier-badge tier-badge--${cls}">${tier}</span>`
227
+ }
228
+
229
+ function sweClass(swe) {
230
+ const val = parseSwe(swe)
231
+ if (val >= 65) return 'swe-high'
232
+ if (val >= 40) return 'swe-mid'
233
+ return 'swe-low'
234
+ }
235
+
236
+ function pingClass(ms) {
237
+ if (ms == null || ms === Infinity) return 'ping-none'
238
+ if (ms < 500) return 'ping-fast'
239
+ if (ms < 1500) return 'ping-medium'
240
+ return 'ping-slow'
241
+ }
242
+
243
+ function formatPing(ms, code) {
244
+ if (ms == null) return '<span class="ping-none">—</span>'
245
+ if (code === '429') return '<span class="ping-slow">429</span>'
246
+ if (code === '000') return '<span class="ping-slow">TIMEOUT</span>'
247
+ return `${ms}ms`
248
+ }
249
+
250
+ function formatAvg(avg) {
251
+ if (avg == null || avg === Infinity || avg > 99000) return '<span class="ping-none">—</span>'
252
+ return `${avg}ms`
253
+ }
254
+
255
+ function stabilityCell(score) {
256
+ if (score == null || score < 0) return '<span class="ping-none">—</span>'
257
+ const cls = score >= 70 ? 'high' : score >= 40 ? 'mid' : 'low'
258
+ return `<div class="stability-cell">
259
+ <div class="stability-bar"><div class="stability-bar__fill stability-bar__fill--${cls}" style="width:${score}%"></div></div>
260
+ <span class="stability-value">${score}</span>
261
+ </div>`
262
+ }
263
+
264
+ function verdictBadge(verdict, httpCode) {
265
+ if (!verdict) return '<span class="verdict-badge verdict--pending">Pending</span>'
266
+ if (httpCode === '429') return '<span class="verdict-badge verdict--ratelimited">⚠️ Rate Limited</span>'
267
+ const cls = verdict.toLowerCase().replace(/\s+/g, '').replace('very', 'very')
268
+ const classMap = {
269
+ 'perfect': 'perfect', 'normal': 'normal', 'slow': 'slow',
270
+ 'spiky': 'spiky', 'veryslow': 'veryslow', 'overloaded': 'overloaded',
271
+ 'unstable': 'unstable', 'notactive': 'notactive', 'pending': 'pending'
272
+ }
273
+ return `<span class="verdict-badge verdict--${classMap[cls] || 'pending'}">${verdict}</span>`
274
+ }
275
+
276
+ function sparkline(history) {
277
+ if (!history || history.length < 2) return ''
278
+ const valid = history.filter(p => p.code === '200' || p.code === '401')
279
+ if (valid.length < 2) return ''
280
+
281
+ const values = valid.map(p => p.ms)
282
+ const max = Math.max(...values, 1)
283
+ const min = Math.min(...values, 0)
284
+ const range = max - min || 1
285
+ const w = 80, h = 22
286
+ const step = w / (values.length - 1)
287
+
288
+ const points = values.map((v, i) => {
289
+ const x = i * step
290
+ const y = h - ((v - min) / range) * (h - 4) - 2
291
+ return `${x.toFixed(1)},${y.toFixed(1)}`
292
+ }).join(' ')
293
+
294
+ const lastVal = values[values.length - 1]
295
+ const color = lastVal < 500 ? '#00ff88' : lastVal < 1500 ? '#ffaa00' : '#ff4444'
296
+
297
+ return `<svg class="sparkline-svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
298
+ <polyline fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" points="${points}" opacity="0.8"/>
299
+ <circle cx="${((values.length - 1) * step).toFixed(1)}" cy="${(h - ((lastVal - min) / range) * (h - 4) - 2).toFixed(1)}" r="2.5" fill="${color}"/>
300
+ </svg>`
301
+ }
302
+
303
+ // ─── Stats ───────────────────────────────────────────────────────────────────
304
+
305
+ function updateStats() {
306
+ const total = models.length
307
+ const online = models.filter(m => m.status === 'up').length
308
+ const onlineWithPing = models.filter(m => m.status === 'up' && m.avg !== Infinity && m.avg < 99000)
309
+ const avgLatency = onlineWithPing.length > 0
310
+ ? Math.round(onlineWithPing.reduce((s, m) => s + m.avg, 0) / onlineWithPing.length)
311
+ : null
312
+ const fastest = [...onlineWithPing].sort((a, b) => a.avg - b.avg)[0]
313
+ const providers = new Set(models.map(m => m.providerKey)).size
314
+
315
+ animateValue($('#stat-total-value'), total)
316
+ animateValue($('#stat-online-value'), online)
317
+ $('#stat-avg-value').textContent = avgLatency != null ? `${avgLatency}ms` : '—'
318
+ $('#stat-best-value').textContent = fastest ? fastest.label : '—'
319
+ animateValue($('#stat-providers-value'), providers)
320
+ }
321
+
322
+ function animateValue(el, newVal) {
323
+ const current = parseInt(el.textContent) || 0
324
+ if (current === newVal) return
325
+ el.textContent = newVal
326
+ }
327
+
328
+ // ─── Provider Filter Dropdown ────────────────────────────────────────────────
329
+
330
+ function populateProviderFilter() {
331
+ const providers = [...new Set(models.map(m => m.providerKey))].sort()
332
+ const origins = {}
333
+ models.forEach(m => { origins[m.providerKey] = m.origin })
334
+
335
+ providerFilter.innerHTML = '<option value="all">All Providers</option>' +
336
+ providers.map(p => `<option value="${p}">${origins[p]} (${models.filter(m => m.providerKey === p).length})</option>`).join('')
337
+ }
338
+
339
+ // ─── Detail Panel ────────────────────────────────────────────────────────────
340
+
341
+ function showDetailPanel(modelId) {
342
+ const model = models.find(m => m.modelId === modelId)
343
+ if (!model) return
344
+ detailPanel.removeAttribute('hidden')
345
+ detailTitle.textContent = model.label
346
+ updateDetailPanel()
347
+ }
348
+
349
+ function updateDetailPanel() {
350
+ const model = models.find(m => m.modelId === selectedModelId)
351
+ if (!model) return
352
+
353
+ const chartSvg = buildDetailChart(model.pingHistory)
354
+
355
+ detailBody.innerHTML = `
356
+ <div class="detail-stat">
357
+ <span class="detail-stat__label">Model ID</span>
358
+ <span class="detail-stat__value" style="font-size:11px; word-break:break-all">${escapeHtml(model.modelId)}</span>
359
+ </div>
360
+ <div class="detail-stat">
361
+ <span class="detail-stat__label">Provider</span>
362
+ <span class="detail-stat__value">${escapeHtml(model.origin)}</span>
363
+ </div>
364
+ <div class="detail-stat">
365
+ <span class="detail-stat__label">Tier</span>
366
+ <span class="detail-stat__value">${tierBadge(model.tier)}</span>
367
+ </div>
368
+ <div class="detail-stat">
369
+ <span class="detail-stat__label">SWE-bench Score</span>
370
+ <span class="detail-stat__value swe-score ${sweClass(model.sweScore)}">${model.sweScore || '—'}</span>
371
+ </div>
372
+ <div class="detail-stat">
373
+ <span class="detail-stat__label">Context Window</span>
374
+ <span class="detail-stat__value">${model.ctx || '—'}</span>
375
+ </div>
376
+ <div class="detail-stat">
377
+ <span class="detail-stat__label">Status</span>
378
+ <span class="detail-stat__value"><span class="status-dot status-dot--${model.status}"></span>${model.status}</span>
379
+ </div>
380
+ <div class="detail-stat">
381
+ <span class="detail-stat__label">Latest Ping</span>
382
+ <span class="detail-stat__value ${pingClass(model.latestPing)}">${formatPing(model.latestPing, model.latestCode)}</span>
383
+ </div>
384
+ <div class="detail-stat">
385
+ <span class="detail-stat__label">Average Latency</span>
386
+ <span class="detail-stat__value ${pingClass(model.avg)}">${formatAvg(model.avg)}</span>
387
+ </div>
388
+ <div class="detail-stat">
389
+ <span class="detail-stat__label">P95 Latency</span>
390
+ <span class="detail-stat__value">${model.p95 != null && model.p95 !== Infinity ? model.p95 + 'ms' : '—'}</span>
391
+ </div>
392
+ <div class="detail-stat">
393
+ <span class="detail-stat__label">Jitter (σ)</span>
394
+ <span class="detail-stat__value">${model.jitter != null && model.jitter !== Infinity ? model.jitter + 'ms' : '—'}</span>
395
+ </div>
396
+ <div class="detail-stat">
397
+ <span class="detail-stat__label">Stability Score</span>
398
+ <span class="detail-stat__value">${stabilityCell(model.stability)}</span>
399
+ </div>
400
+ <div class="detail-stat">
401
+ <span class="detail-stat__label">Verdict</span>
402
+ <span class="detail-stat__value">${verdictBadge(model.verdict, model.httpCode)}</span>
403
+ </div>
404
+ <div class="detail-stat">
405
+ <span class="detail-stat__label">Uptime</span>
406
+ <span class="detail-stat__value">${model.uptime > 0 ? model.uptime + '%' : '—'}</span>
407
+ </div>
408
+ <div class="detail-stat">
409
+ <span class="detail-stat__label">Ping Count</span>
410
+ <span class="detail-stat__value">${model.pingCount}</span>
411
+ </div>
412
+ <div class="detail-stat">
413
+ <span class="detail-stat__label">API Key</span>
414
+ <span class="detail-stat__value">${model.hasApiKey ? '✅ Configured' : '❌ Missing'}</span>
415
+ </div>
416
+ <div class="detail-chart">
417
+ <div class="detail-chart__title">Latency Trend (last 20 pings)</div>
418
+ ${chartSvg}
419
+ </div>
420
+ `
421
+ }
422
+
423
+ function buildDetailChart(history) {
424
+ if (!history || history.length < 2) return '<div style="color:var(--color-text-dim); text-align:center; padding:20px;">Waiting for ping data...</div>'
425
+
426
+ const valid = history.filter(p => p.code === '200' || p.code === '401')
427
+ if (valid.length < 2) return '<div style="color:var(--color-text-dim); text-align:center; padding:20px;">Not enough data yet...</div>'
428
+
429
+ const values = valid.map(p => p.ms)
430
+ const max = Math.max(...values, 1)
431
+ const min = Math.min(...values, 0)
432
+ const range = max - min || 1
433
+ const w = 340, h = 100
434
+ const padding = 4
435
+
436
+ const points = values.map((v, i) => {
437
+ const x = padding + i * ((w - 2 * padding) / (values.length - 1))
438
+ const y = padding + (h - 2 * padding) - ((v - min) / range) * (h - 2 * padding)
439
+ return [x.toFixed(1), y.toFixed(1)]
440
+ })
441
+
442
+ const linePoints = points.map(p => p.join(',')).join(' ')
443
+ const areaPoints = `${points[0][0]},${h - padding} ${linePoints} ${points[points.length - 1][0]},${h - padding}`
444
+
445
+ return `<svg width="100%" viewBox="0 0 ${w} ${h}" style="display:block;">
446
+ <defs>
447
+ <linearGradient id="chart-grad" x1="0" y1="0" x2="0" y2="1">
448
+ <stop offset="0%" stop-color="var(--color-accent)" stop-opacity="0.3"/>
449
+ <stop offset="100%" stop-color="var(--color-accent)" stop-opacity="0.02"/>
450
+ </linearGradient>
451
+ </defs>
452
+ <polygon fill="url(#chart-grad)" points="${areaPoints}"/>
453
+ <polyline fill="none" stroke="var(--color-accent)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" points="${linePoints}"/>
454
+ ${points.map(([x, y], i) => i === points.length - 1 ? `<circle cx="${x}" cy="${y}" r="3.5" fill="var(--color-accent)" stroke="var(--color-bg)" stroke-width="1.5"/>` : '').join('')}
455
+ <text x="${padding}" y="${h - 2}" font-size="9" fill="var(--color-text-dim)" font-family="var(--font-mono)">${min}ms</text>
456
+ <text x="${w - padding}" y="${padding + 8}" font-size="9" fill="var(--color-text-dim)" font-family="var(--font-mono)" text-anchor="end">${max}ms</text>
457
+ </svg>`
458
+ }
459
+
460
+ // ═══════ SETTINGS PAGE ═══════════════════════════════════════════════════════
461
+
462
+ async function loadSettingsPage() {
463
+ try {
464
+ const resp = await fetch('/api/config')
465
+ configData = await resp.json()
466
+ renderSettingsProviders()
467
+ } catch (e) {
468
+ showToast('Failed to load settings', 'error')
469
+ }
470
+ }
471
+
472
+ function renderSettingsProviders(searchFilter = '') {
473
+ if (!configData) return
474
+ const container = $('#settings-providers')
475
+ const entries = Object.entries(configData.providers)
476
+ .filter(([key, p]) => {
477
+ if (!searchFilter) return true
478
+ const q = searchFilter.toLowerCase()
479
+ return p.name.toLowerCase().includes(q) || key.toLowerCase().includes(q)
480
+ })
481
+ .sort((a, b) => a[1].name.localeCompare(b[1].name))
482
+
483
+ container.innerHTML = entries.map(([key, p]) => {
484
+ const isRevealed = revealedKeys.has(key)
485
+ const maskedKey = p.hasKey ? (isRevealed ? (p.maskedKey || '••••••••') : maskKey(p.maskedKey || '')) : ''
486
+
487
+ return `<div class="settings-card" data-provider="${key}" id="settings-card-${key}">
488
+ <div class="settings-card__header" onclick="toggleSettingsCard('${key}')">
489
+ <div class="settings-card__icon">🔌</div>
490
+ <div class="settings-card__info">
491
+ <div class="settings-card__name">${escapeHtml(p.name)}</div>
492
+ <div class="settings-card__meta">${p.modelCount} models · ${escapeHtml(key)}</div>
493
+ </div>
494
+ <span class="settings-card__status ${p.hasKey ? 'settings-card__status--configured' : 'settings-card__status--missing'}">
495
+ ${p.hasKey ? '✅ Active' : '🔑 No Key'}
496
+ </span>
497
+ <span class="settings-card__toggle-icon">▼</span>
498
+ </div>
499
+ <div class="settings-card__body">
500
+ <div class="settings-card__content">
501
+ ${p.hasKey ? `
502
+ <div class="api-key-group">
503
+ <label class="api-key-group__label">Current API Key</label>
504
+ <div class="api-key-display">
505
+ <span class="api-key-display__value" id="key-display-${key}">${maskedKey}</span>
506
+ <div class="api-key-display__actions">
507
+ <button class="btn btn--sm btn--icon" onclick="toggleRevealKey('${key}')" title="${isRevealed ? 'Hide' : 'Reveal'}">
508
+ ${isRevealed ? '🙈' : '👁️'}
509
+ </button>
510
+ <button class="btn btn--sm btn--icon" onclick="copyKey('${key}')" title="Copy">📋</button>
511
+ <button class="btn btn--sm btn--danger" onclick="deleteKey('${key}')" title="Delete Key">🗑️</button>
512
+ </div>
513
+ </div>
514
+ </div>
515
+ ` : ''}
516
+ <div class="api-key-group">
517
+ <label class="api-key-group__label">${p.hasKey ? 'Update API Key' : 'Add API Key'}</label>
518
+ <div class="api-key-group__row">
519
+ <input type="password" class="api-key-group__input" id="key-input-${key}"
520
+ placeholder="Enter your API key..." autocomplete="off">
521
+ <button class="btn btn--sm btn--success" onclick="saveKey('${key}')">
522
+ ${p.hasKey ? 'Update' : 'Save'}
523
+ </button>
524
+ </div>
525
+ </div>
526
+ <div class="settings-card__enabled">
527
+ <span class="settings-card__enabled-label">Provider Enabled</span>
528
+ <label class="toggle-switch">
529
+ <input type="checkbox" ${p.enabled !== false ? 'checked' : ''} onchange="toggleProvider('${key}', this.checked)">
530
+ <span class="toggle-switch__slider"></span>
531
+ </label>
532
+ </div>
533
+ </div>
534
+ </div>
535
+ </div>`
536
+ }).join('')
537
+ }
538
+
539
+ // Settings: Global actions
540
+ window.toggleSettingsCard = function(key) {
541
+ const card = $(`#settings-card-${key}`)
542
+ if (card) card.classList.toggle('settings-card--expanded')
543
+ }
544
+
545
+ window.toggleRevealKey = async function(key) {
546
+ if (revealedKeys.has(key)) {
547
+ revealedKeys.delete(key)
548
+ renderSettingsProviders($('#settings-search')?.value || '')
549
+ return
550
+ }
551
+
552
+ try {
553
+ const resp = await fetch(`/api/key/${key}`)
554
+ const data = await resp.json()
555
+ if (data.key) {
556
+ revealedKeys.add(key)
557
+ const display = $(`#key-display-${key}`)
558
+ if (display) display.textContent = data.key
559
+ // Re-render to update button icon
560
+ const card = $(`#settings-card-${key}`)
561
+ const wasExpanded = card?.classList.contains('settings-card--expanded')
562
+ renderSettingsProviders($('#settings-search')?.value || '')
563
+ if (wasExpanded) $(`#settings-card-${key}`)?.classList.add('settings-card--expanded')
564
+ }
565
+ } catch {
566
+ showToast('Failed to reveal key', 'error')
567
+ }
568
+ }
569
+
570
+ window.copyKey = async function(key) {
571
+ try {
572
+ const resp = await fetch(`/api/key/${key}`)
573
+ const data = await resp.json()
574
+ if (data.key) {
575
+ await navigator.clipboard.writeText(data.key)
576
+ showToast('API key copied to clipboard', 'success')
577
+ } else {
578
+ showToast('No key to copy', 'warning')
579
+ }
580
+ } catch {
581
+ showToast('Failed to copy key', 'error')
582
+ }
583
+ }
584
+
585
+ window.saveKey = async function(key) {
586
+ const input = $(`#key-input-${key}`)
587
+ const value = input?.value?.trim()
588
+ if (!value) {
589
+ showToast('Please enter an API key', 'warning')
590
+ return
591
+ }
592
+
593
+ try {
594
+ const resp = await fetch('/api/settings', {
595
+ method: 'POST',
596
+ headers: { 'Content-Type': 'application/json' },
597
+ body: JSON.stringify({ apiKeys: { [key]: value } })
598
+ })
599
+ const result = await resp.json()
600
+ if (result.success) {
601
+ showToast(`API key for ${key} saved successfully!`, 'success')
602
+ input.value = ''
603
+ revealedKeys.delete(key)
604
+ await loadSettingsPage()
605
+ // Re-expand the card
606
+ $(`#settings-card-${key}`)?.classList.add('settings-card--expanded')
607
+ } else {
608
+ showToast(result.error || 'Failed to save', 'error')
609
+ }
610
+ } catch {
611
+ showToast('Network error while saving', 'error')
612
+ }
613
+ }
614
+
615
+ window.deleteKey = async function(key) {
616
+ if (!confirm(`Are you sure you want to delete the API key for "${key}"?`)) return
617
+
618
+ try {
619
+ const resp = await fetch('/api/settings', {
620
+ method: 'POST',
621
+ headers: { 'Content-Type': 'application/json' },
622
+ body: JSON.stringify({ apiKeys: { [key]: '' } })
623
+ })
624
+ const result = await resp.json()
625
+ if (result.success) {
626
+ showToast(`API key for ${key} deleted`, 'info')
627
+ revealedKeys.delete(key)
628
+ await loadSettingsPage()
629
+ } else {
630
+ showToast(result.error || 'Failed to delete', 'error')
631
+ }
632
+ } catch {
633
+ showToast('Network error while deleting', 'error')
634
+ }
635
+ }
636
+
637
+ window.toggleProvider = async function(key, enabled) {
638
+ try {
639
+ const resp = await fetch('/api/settings', {
640
+ method: 'POST',
641
+ headers: { 'Content-Type': 'application/json' },
642
+ body: JSON.stringify({ providers: { [key]: { enabled } } })
643
+ })
644
+ const result = await resp.json()
645
+ if (result.success) {
646
+ showToast(`${key} ${enabled ? 'enabled' : 'disabled'}`, 'success')
647
+ } else {
648
+ showToast(result.error || 'Failed to toggle', 'error')
649
+ }
650
+ } catch {
651
+ showToast('Network error', 'error')
652
+ }
653
+ }
654
+
655
+ function maskKey(key) {
656
+ if (!key || key.length < 8) return '••••••••'
657
+ return '••••••••' + key.slice(-4)
658
+ }
659
+
660
+ // Settings search
661
+ $('#settings-search')?.addEventListener('input', (e) => {
662
+ renderSettingsProviders(e.target.value)
663
+ })
664
+
665
+ $('#settings-expand-all')?.addEventListener('click', () => {
666
+ $$('.settings-card').forEach(c => c.classList.add('settings-card--expanded'))
667
+ })
668
+ $('#settings-collapse-all')?.addEventListener('click', () => {
669
+ $$('.settings-card').forEach(c => c.classList.remove('settings-card--expanded'))
670
+ })
671
+
672
+ // ═══════ ANALYTICS VIEW ═════════════════════════════════════════════════════
673
+
674
+ function renderAnalytics() {
675
+ if (!models.length) return
676
+ renderProviderHealth()
677
+ renderLeaderboard()
678
+ renderTierDistribution()
679
+ }
680
+
681
+ function renderProviderHealth() {
682
+ const providerMap = {}
683
+ models.forEach(m => {
684
+ if (!providerMap[m.origin]) providerMap[m.origin] = { total: 0, online: 0, key: m.providerKey }
685
+ providerMap[m.origin].total++
686
+ if (m.status === 'up') providerMap[m.origin].online++
687
+ })
688
+
689
+ const entries = Object.entries(providerMap).sort((a, b) => {
690
+ const pctA = a[1].online / a[1].total
691
+ const pctB = b[1].online / b[1].total
692
+ return pctB - pctA
693
+ })
694
+
695
+ const html = entries.map(([name, data]) => {
696
+ const pct = data.total > 0 ? Math.round((data.online / data.total) * 100) : 0
697
+ return `<div class="provider-health-item">
698
+ <span class="provider-health__name">${escapeHtml(name)}</span>
699
+ <div class="provider-health__bar"><div class="provider-health__fill" style="width:${pct}%"></div></div>
700
+ <span class="provider-health__pct ${pct > 70 ? 'ping-fast' : pct > 30 ? 'ping-medium' : 'ping-slow'}">${pct}%</span>
701
+ </div>`
702
+ }).join('')
703
+
704
+ $('#provider-health-body').innerHTML = html || '<div style="color:var(--color-text-dim);">Waiting for data...</div>'
705
+ }
706
+
707
+ function renderLeaderboard() {
708
+ const online = models.filter(m => m.status === 'up' && m.avg !== Infinity && m.avg < 99000)
709
+ const top10 = [...online].sort((a, b) => a.avg - b.avg).slice(0, 10)
710
+
711
+ const html = top10.map((m, i) => {
712
+ const rankClass = i < 3 ? `leaderboard__rank--${i + 1}` : ''
713
+ const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : (i + 1)
714
+ return `<div class="leaderboard-item">
715
+ <div class="leaderboard__rank ${rankClass}">${medal}</div>
716
+ <span class="leaderboard__name">${escapeHtml(m.label)}</span>
717
+ <span class="leaderboard__latency">${m.avg}ms</span>
718
+ </div>`
719
+ }).join('')
720
+
721
+ $('#leaderboard-body').innerHTML = html || '<div style="color:var(--color-text-dim);">Waiting for ping data...</div>'
722
+ }
723
+
724
+ function renderTierDistribution() {
725
+ const tierColors = { 'S+': '#ffd700', 'S': '#ff8c00', 'A+': '#00c8ff', 'A': '#3ddc84', 'A-': '#7ecf7e', 'B+': '#a8a8c8', 'B': '#808098', 'C': '#606078' }
726
+ const tierCounts = {}
727
+ models.forEach(m => { tierCounts[m.tier] = (tierCounts[m.tier] || 0) + 1 })
728
+ const maxCount = Math.max(...Object.values(tierCounts), 1)
729
+
730
+ const tiers = ['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
731
+ const html = tiers.map(t => {
732
+ const count = tierCounts[t] || 0
733
+ const pct = (count / maxCount) * 100
734
+ return `<div class="tier-dist-item">
735
+ <div class="tier-dist__badge">${tierBadge(t)}</div>
736
+ <div class="tier-dist__bar"><div class="tier-dist__fill" style="width:${pct}%; background:${tierColors[t]}"></div></div>
737
+ <span class="tier-dist__count">${count}</span>
738
+ </div>`
739
+ }).join('')
740
+
741
+ $('#tier-dist-body').innerHTML = html
742
+ }
743
+
744
+ // ═══════ EXPORT ═════════════════════════════════════════════════════════════
745
+
746
+ const exportModal = $('#export-modal')
747
+ const exportBtn = $('#export-btn')
748
+ const exportClose = $('#export-close')
749
+
750
+ exportBtn?.addEventListener('click', () => { exportModal.hidden = false })
751
+ exportClose?.addEventListener('click', () => { exportModal.hidden = true })
752
+ exportModal?.addEventListener('click', (e) => { if (e.target === exportModal) exportModal.hidden = true })
753
+
754
+ $('#export-json')?.addEventListener('click', () => {
755
+ const data = JSON.stringify(getFilteredModels(), null, 2)
756
+ downloadFile(data, 'free-coding-models-export.json', 'application/json')
757
+ showToast('Exported as JSON', 'success')
758
+ exportModal.hidden = true
759
+ })
760
+
761
+ $('#export-csv')?.addEventListener('click', () => {
762
+ const filtered = getFilteredModels()
763
+ const headers = ['Rank', 'Tier', 'Model', 'Provider', 'SWE%', 'Context', 'LatestPing', 'AvgPing', 'Stability', 'Verdict', 'Uptime']
764
+ const rows = filtered.map((m, i) =>
765
+ [i + 1, m.tier, m.label, m.origin, m.sweScore || '', m.ctx || '', m.latestPing || '', m.avg === Infinity ? '' : m.avg, m.stability || '', m.verdict || '', m.uptime || ''].join(',')
766
+ )
767
+ const csv = [headers.join(','), ...rows].join('\n')
768
+ downloadFile(csv, 'free-coding-models-export.csv', 'text/csv')
769
+ showToast('Exported as CSV', 'success')
770
+ exportModal.hidden = true
771
+ })
772
+
773
+ $('#export-clipboard')?.addEventListener('click', async () => {
774
+ const filtered = getFilteredModels()
775
+ const online = filtered.filter(m => m.status === 'up')
776
+ const text = `free-coding-models Dashboard Export\n` +
777
+ `Total: ${filtered.length} | Online: ${online.length}\n\n` +
778
+ online.slice(0, 20).map((m, i) =>
779
+ `${i + 1}. ${m.label} [${m.tier}] — ${m.avg !== Infinity ? m.avg + 'ms' : 'N/A'} (${m.origin})`
780
+ ).join('\n')
781
+ await navigator.clipboard.writeText(text)
782
+ showToast('Copied to clipboard', 'success')
783
+ exportModal.hidden = true
784
+ })
785
+
786
+ function downloadFile(content, filename, type) {
787
+ const blob = new Blob([content], { type })
788
+ const url = URL.createObjectURL(blob)
789
+ const a = document.createElement('a')
790
+ a.href = url; a.download = filename; a.click()
791
+ URL.revokeObjectURL(url)
792
+ }
793
+
794
+ // ─── Event Handlers ──────────────────────────────────────────────────────────
795
+
796
+ // Theme toggle
797
+ const toggleTheme = () => {
798
+ const html = document.documentElement
799
+ const current = html.getAttribute('data-theme')
800
+ html.setAttribute('data-theme', current === 'dark' ? 'light' : 'dark')
801
+ }
802
+ themeToggle?.addEventListener('click', toggleTheme)
803
+ $('#sidebar-theme-toggle')?.addEventListener('click', toggleTheme)
804
+
805
+ // Search
806
+ searchInput?.addEventListener('input', (e) => {
807
+ searchQuery = e.target.value
808
+ renderTable()
809
+ })
810
+
811
+ // Ctrl+K shortcut
812
+ document.addEventListener('keydown', (e) => {
813
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
814
+ e.preventDefault()
815
+ if (currentView !== 'dashboard') switchView('dashboard')
816
+ searchInput?.focus()
817
+ }
818
+ if (e.key === 'Escape') {
819
+ if (!detailPanel.hidden) { detailPanel.hidden = true; selectedModelId = null }
820
+ if (!exportModal.hidden) exportModal.hidden = true
821
+ }
822
+ })
823
+
824
+ // Tier filter
825
+ $('#tier-filters')?.addEventListener('click', (e) => {
826
+ const btn = e.target.closest('.tier-btn')
827
+ if (!btn) return
828
+ filterTier = btn.dataset.tier
829
+ $$('.tier-btn').forEach(b => b.classList.remove('tier-btn--active'))
830
+ btn.classList.add('tier-btn--active')
831
+ renderTable()
832
+ })
833
+
834
+ // Status filter
835
+ $('#status-filters')?.addEventListener('click', (e) => {
836
+ const btn = e.target.closest('.status-btn')
837
+ if (!btn) return
838
+ filterStatus = btn.dataset.status
839
+ $$('.status-btn').forEach(b => b.classList.remove('status-btn--active'))
840
+ btn.classList.add('status-btn--active')
841
+ renderTable()
842
+ })
843
+
844
+ // Provider filter
845
+ providerFilter?.addEventListener('change', (e) => {
846
+ filterProvider = e.target.value
847
+ renderTable()
848
+ })
849
+
850
+ // Table header sorting
851
+ $('#models-table thead')?.addEventListener('click', (e) => {
852
+ const th = e.target.closest('th.sortable')
853
+ if (!th) return
854
+ const col = th.dataset.sort
855
+ if (sortColumn === col) {
856
+ sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'
857
+ } else {
858
+ sortColumn = col
859
+ sortDirection = 'asc'
860
+ }
861
+ $$('th.sortable').forEach(t => t.classList.remove('sort-active'))
862
+ th.classList.add('sort-active')
863
+ renderTable()
864
+ })
865
+
866
+ // Detail panel close
867
+ detailClose?.addEventListener('click', () => {
868
+ detailPanel.hidden = true
869
+ selectedModelId = null
870
+ })
871
+
872
+ // ─── Utility Functions ───────────────────────────────────────────────────────
873
+
874
+ const TIER_RANKS = { 'S+': 0, 'S': 1, 'A+': 2, 'A': 3, 'A-': 4, 'B+': 5, 'B': 6, 'C': 7 }
875
+ function tierRank(tier) { return TIER_RANKS[tier] ?? 99 }
876
+
877
+ const VERDICT_RANKS = { 'Perfect': 0, 'Normal': 1, 'Slow': 2, 'Spiky': 3, 'Very Slow': 4, 'Overloaded': 5, 'Unstable': 6, 'Not Active': 7, 'Pending': 8 }
878
+ function verdictRank(verdict) { return VERDICT_RANKS[verdict] ?? 99 }
879
+
880
+ function parseSwe(s) {
881
+ if (!s || s === '—') return 0
882
+ return parseFloat(s.replace('%', '')) || 0
883
+ }
884
+
885
+ function parseCtx(c) {
886
+ if (!c || c === '—') return 0
887
+ const s = c.toLowerCase()
888
+ if (s.includes('m')) return parseFloat(s) * 1000
889
+ if (s.includes('k')) return parseFloat(s)
890
+ return 0
891
+ }
892
+
893
+ function escapeHtml(str) {
894
+ if (!str) return ''
895
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
896
+ }
897
+
898
+ // ─── Initialize ──────────────────────────────────────────────────────────────
899
+
900
+ connectSSE()