free-coding-models 0.3.35 → 0.3.36
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 +26 -13
- package/README.md +11 -10
- package/bin/free-coding-models.js +8 -0
- package/package.json +3 -2
- package/src/render-table.js +1 -8
- package/src/utils.js +4 -0
- package/web/app.js +900 -0
- package/web/index.html +318 -0
- package/web/server.js +317 -0
- package/web/styles.css +963 -0
package/web/app.js
ADDED
|
@@ -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">×</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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// ─── Initialize ──────────────────────────────────────────────────────────────
|
|
899
|
+
|
|
900
|
+
connectSSE()
|