free-coding-models 0.5.0 → 0.5.1
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/README.md +9 -1
- package/bin/free-coding-models.js +10 -0
- package/changelog/v0.5.1.md +24 -0
- package/package.json +7 -2
- package/src/core/router-daemon.js +166 -1
- package/src/core/utils.js +2 -0
- package/src/tui/cli-help.js +2 -0
- package/src/tui/render-table.js +1 -1
- package/web/README.md +8 -5
- package/web/dist/assets/index-ByGf4Kq-.js +14 -0
- package/web/dist/assets/index-Ds7wmHBv.css +1 -0
- package/web/dist/index.html +3 -6
- package/web/index.html +1 -4
- package/web/package.json +11 -0
- package/web/server.js +606 -211
- package/web/src/App.jsx +54 -12
- package/web/src/components/analytics/AnalyticsView.jsx +10 -4
- package/web/src/components/atoms/AILatencyCell.jsx +38 -0
- package/web/src/components/atoms/AILatencyCell.module.css +43 -0
- package/web/src/components/atoms/HealthCell.jsx +53 -0
- package/web/src/components/atoms/HealthCell.module.css +15 -0
- package/web/src/components/atoms/LastPingCell.jsx +35 -0
- package/web/src/components/atoms/LastPingCell.module.css +35 -0
- package/web/src/components/atoms/MoodCell.jsx +25 -0
- package/web/src/components/atoms/MoodCell.module.css +6 -0
- package/web/src/components/atoms/RankCell.jsx +9 -0
- package/web/src/components/atoms/RankCell.module.css +9 -0
- package/web/src/components/atoms/TPSCell.jsx +36 -0
- package/web/src/components/atoms/TPSCell.module.css +38 -0
- package/web/src/components/atoms/VerdictBadge.jsx +30 -7
- package/web/src/components/atoms/VerdictBadge.module.css +24 -15
- package/web/src/components/dashboard/ExportModal.jsx +9 -4
- package/web/src/components/dashboard/FilterBar.jsx +112 -10
- package/web/src/components/dashboard/FilterBar.module.css +86 -1
- package/web/src/components/dashboard/ModelTable.jsx +293 -52
- package/web/src/components/dashboard/ModelTable.module.css +131 -33
- package/web/src/components/dashboard/StatsBar.jsx +7 -5
- package/web/src/components/layout/Footer.jsx +1 -1
- package/web/src/components/layout/Header.jsx +43 -9
- package/web/src/components/layout/Header.module.css +38 -4
- package/web/src/components/layout/Sidebar.jsx +19 -11
- package/web/src/components/layout/Sidebar.module.css +15 -5
- package/web/src/components/settings/SettingsView.jsx +24 -6
- package/web/src/components/settings/SettingsView.module.css +0 -1
- package/web/src/global.css +70 -73
- package/web/src/hooks/useFilter.js +117 -25
- package/web/src/hooks/useSSE.js +33 -9
- package/web/src/hooks/useSocket.js +200 -0
- package/web/vite.config.js +41 -0
- package/web/dist/assets/index-CGN-0_A0.css +0 -1
- package/web/dist/assets/index-CvMUM9Jr.js +0 -11
package/web/server.js
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file web/server.js
|
|
3
|
-
* @description HTTP server for the free-coding-models Web Dashboard
|
|
3
|
+
* @description HTTP + Socket.IO/SSE server for the free-coding-models realtime Web Dashboard.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* @details
|
|
6
|
+
* 📖 This server intentionally mirrors the TUI health loop instead of exposing a
|
|
7
|
+
* slow request/response snapshot. The browser gets per-model ping state, frequent
|
|
8
|
+
* updates while probes complete, and the same startup speed burst → normal → idle
|
|
9
|
+
* slow cadence used by the terminal UI.
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
11
|
+
* Realtime transport strategy:
|
|
12
|
+
* - Socket.IO is the primary channel for the local web app.
|
|
13
|
+
* - `/api/events` keeps an SSE stream alive as a zero-dependency fallback.
|
|
14
|
+
* - `/api/models` remains a plain JSON endpoint for polling/fallback clients.
|
|
15
|
+
*
|
|
16
|
+
* @functions
|
|
17
|
+
* → startWebServer(port, options) — Start the dashboard server and realtime loops
|
|
18
|
+
* → inspectExistingWebServer(port) — Detect if a port already hosts this dashboard
|
|
19
|
+
* → findAvailablePort(startPort, maxAttempts) — Find a local fallback port
|
|
20
|
+
*
|
|
21
|
+
* @exports startWebServer, inspectExistingWebServer, findAvailablePort
|
|
19
22
|
*/
|
|
20
23
|
|
|
21
24
|
import { createServer } from 'node:http'
|
|
@@ -23,23 +26,58 @@ import { readFileSync, existsSync } from 'node:fs'
|
|
|
23
26
|
import { join, dirname, extname } from 'node:path'
|
|
24
27
|
import { fileURLToPath } from 'node:url'
|
|
25
28
|
import { exec } from 'node:child_process'
|
|
29
|
+
import { Server } from 'socket.io'
|
|
26
30
|
|
|
27
31
|
import { sources, MODELS } from '../sources.js'
|
|
28
32
|
import { loadConfig, getApiKey, saveConfig, isProviderEnabled } from '../src/core/config.js'
|
|
29
33
|
import { ping } from '../src/core/ping.js'
|
|
30
34
|
import {
|
|
31
35
|
getAvg, getVerdict, getUptime, getP95, getJitter,
|
|
32
|
-
getStabilityScore,
|
|
36
|
+
getStabilityScore,
|
|
33
37
|
} from '../src/core/utils.js'
|
|
38
|
+
import { benchmarkModel, BENCHMARK_TIMEOUT_MS } from '../src/core/benchmark.js'
|
|
39
|
+
import {
|
|
40
|
+
PING_MODE_INTERVALS,
|
|
41
|
+
PING_MODE_CYCLE,
|
|
42
|
+
SPEED_MODE_DURATION_MS,
|
|
43
|
+
IDLE_SLOW_AFTER_MS,
|
|
44
|
+
} from '../src/tui/tui-state.js'
|
|
34
45
|
|
|
35
46
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
36
47
|
const SERVER_SIGNATURE = 'free-coding-models-web'
|
|
48
|
+
const BROADCAST_THROTTLE_MS = 80
|
|
49
|
+
const MAX_PING_HISTORY = 60
|
|
50
|
+
const GLOBAL_BENCHMARK_CONCURRENCY = 5
|
|
51
|
+
const DEFAULT_WEB_PORT = 3333
|
|
52
|
+
const BODY_LIMIT_BYTES = 1024 * 1024
|
|
37
53
|
|
|
38
|
-
// ───
|
|
54
|
+
// ─── Mutable server state ───────────────────────────────────────────────────
|
|
39
55
|
|
|
40
56
|
let config = loadConfig()
|
|
57
|
+
let io = null
|
|
58
|
+
let pingLoopTimer = null
|
|
59
|
+
let broadcastTimer = null
|
|
60
|
+
let heartbeatTimer = null
|
|
61
|
+
let startedServer = null
|
|
62
|
+
|
|
63
|
+
const sseClients = new Set()
|
|
64
|
+
|
|
65
|
+
const runtime = {
|
|
66
|
+
pingMode: 'speed',
|
|
67
|
+
pingModeSource: 'startup',
|
|
68
|
+
activePingInterval: PING_MODE_INTERVALS.speed,
|
|
69
|
+
speedModeUntil: Date.now() + SPEED_MODE_DURATION_MS,
|
|
70
|
+
lastUserActivityAt: Date.now(),
|
|
71
|
+
resumeSpeedOnActivity: false,
|
|
72
|
+
lastPingTime: Date.now(),
|
|
73
|
+
nextPingAt: Date.now(),
|
|
74
|
+
pendingPings: 0,
|
|
75
|
+
pingRound: 0,
|
|
76
|
+
globalBenchmarkRunning: false,
|
|
77
|
+
globalBenchmarkTotal: 0,
|
|
78
|
+
globalBenchmarkCompleted: 0,
|
|
79
|
+
}
|
|
41
80
|
|
|
42
|
-
// Build results array from MODELS (same shape as the TUI)
|
|
43
81
|
const results = MODELS.map(([modelId, label, tier, sweScore, ctx, providerKey], idx) => ({
|
|
44
82
|
idx: idx + 1,
|
|
45
83
|
modelId,
|
|
@@ -55,105 +93,203 @@ const results = MODELS.map(([modelId, label, tier, sweScore, ctx, providerKey],
|
|
|
55
93
|
url: sources[providerKey]?.url || null,
|
|
56
94
|
cliOnly: sources[providerKey]?.cliOnly || false,
|
|
57
95
|
zenOnly: sources[providerKey]?.zenOnly || false,
|
|
96
|
+
isPinging: false,
|
|
58
97
|
}))
|
|
59
98
|
|
|
60
|
-
|
|
61
|
-
const
|
|
99
|
+
const benchmarkRunning = new Set()
|
|
100
|
+
const benchmarkResults = new Map()
|
|
62
101
|
|
|
63
|
-
// ───
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
102
|
+
// ─── Shared state helpers ───────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
function benchmarkKey(providerKey, modelId) {
|
|
105
|
+
return `${providerKey}/${modelId}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getResultKey(result) {
|
|
109
|
+
return benchmarkKey(result.providerKey, result.modelId)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getResult(providerKey, modelId) {
|
|
113
|
+
return results.find((r) => r.providerKey === providerKey && r.modelId === modelId) || null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function noteUserActivity() {
|
|
117
|
+
runtime.lastUserActivityAt = Date.now()
|
|
118
|
+
if (runtime.pingMode === 'forced') return
|
|
119
|
+
if (runtime.resumeSpeedOnActivity) setPingMode('speed', 'activity')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function setPingMode(nextMode, source = 'manual') {
|
|
123
|
+
const mode = PING_MODE_INTERVALS[nextMode] ? nextMode : 'normal'
|
|
124
|
+
runtime.pingMode = mode
|
|
125
|
+
runtime.pingModeSource = source
|
|
126
|
+
runtime.activePingInterval = PING_MODE_INTERVALS[mode]
|
|
127
|
+
runtime.speedModeUntil = mode === 'speed' ? Date.now() + SPEED_MODE_DURATION_MS : null
|
|
128
|
+
runtime.resumeSpeedOnActivity = source === 'idle'
|
|
129
|
+
scheduleNextPing()
|
|
130
|
+
broadcastUpdate({ immediate: true })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function cyclePingMode() {
|
|
134
|
+
const idx = PING_MODE_CYCLE.indexOf(runtime.pingMode)
|
|
135
|
+
setPingMode(PING_MODE_CYCLE[(idx + 1) % PING_MODE_CYCLE.length] || 'normal')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function refreshPingMode() {
|
|
139
|
+
const now = Date.now()
|
|
140
|
+
if (runtime.pingMode === 'forced') return
|
|
141
|
+
|
|
142
|
+
if (runtime.speedModeUntil && now >= runtime.speedModeUntil) {
|
|
143
|
+
setPingMode('normal', 'auto')
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (now - runtime.lastUserActivityAt >= IDLE_SLOW_AFTER_MS) {
|
|
148
|
+
if (runtime.pingMode !== 'slow' || runtime.pingModeSource !== 'idle') {
|
|
149
|
+
setPingMode('slow', 'idle')
|
|
150
|
+
} else {
|
|
151
|
+
runtime.resumeSpeedOnActivity = true
|
|
152
|
+
}
|
|
109
153
|
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function scheduleNextPing() {
|
|
157
|
+
if (!startedServer?.listening) return
|
|
158
|
+
clearTimeout(pingLoopTimer)
|
|
159
|
+
refreshPingMode()
|
|
160
|
+
const elapsed = Date.now() - runtime.lastPingTime
|
|
161
|
+
const delay = Math.max(0, runtime.activePingInterval - elapsed)
|
|
162
|
+
runtime.nextPingAt = Date.now() + delay
|
|
163
|
+
pingLoopTimer = setTimeout(startPingCycle, delay)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function trimPingHistory(result) {
|
|
167
|
+
if (result.pings.length > MAX_PING_HISTORY) result.pings = result.pings.slice(-MAX_PING_HISTORY)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function updateHealthFromPing(result, pingResult, hasApiKey) {
|
|
171
|
+
const code = String(pingResult.code || 'ERR')
|
|
172
|
+
result.httpCode = code
|
|
173
|
+
|
|
174
|
+
// 📖 Match the TUI: every probe contributes to availability history. Average,
|
|
175
|
+
// 📖 p95, and jitter still ignore non-measurable codes through src/core/utils.js.
|
|
176
|
+
result.pings.push({ ms: pingResult.ms, code })
|
|
177
|
+
trimPingHistory(result)
|
|
110
178
|
|
|
111
|
-
|
|
179
|
+
if (code === '200') result.status = 'up'
|
|
180
|
+
else if (code === '000') result.status = 'timeout'
|
|
181
|
+
else if (code === '401' || code === '403') result.status = hasApiKey ? 'auth_error' : 'noauth'
|
|
182
|
+
else result.status = 'down'
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function updateHealthFromBenchmark(result, benchmarkResult) {
|
|
186
|
+
if (!result || !benchmarkResult) return
|
|
187
|
+
if (benchmarkResult.ok) {
|
|
188
|
+
result.status = 'up'
|
|
189
|
+
result.httpCode = '200'
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const code = String(benchmarkResult.code || 'ERR')
|
|
194
|
+
if (code === 'TIMEOUT') result.status = 'timeout'
|
|
195
|
+
else if (code === '401' || code === '403') result.status = getApiKey(config, result.providerKey) ? 'auth_error' : 'noauth'
|
|
196
|
+
else if (code !== 'ERR' && code !== 'UNSUPPORTED') result.status = 'down'
|
|
197
|
+
result.httpCode = code
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function pingModel(result) {
|
|
201
|
+
if (!result || result.isPinging || result.cliOnly || !result.url || !isProviderEnabled(config, result.providerKey)) return
|
|
202
|
+
|
|
203
|
+
result.isPinging = true
|
|
204
|
+
runtime.pendingPings += 1
|
|
112
205
|
broadcastUpdate()
|
|
113
|
-
|
|
206
|
+
|
|
207
|
+
const apiKey = getApiKey(config, result.providerKey) ?? null
|
|
208
|
+
try {
|
|
209
|
+
const pingResult = await ping(apiKey, result.modelId, result.providerKey, result.url)
|
|
210
|
+
updateHealthFromPing(result, pingResult, !!apiKey)
|
|
211
|
+
} catch (err) {
|
|
212
|
+
updateHealthFromPing(result, { code: '000', ms: null, error: err?.message || 'Ping failed' }, !!apiKey)
|
|
213
|
+
} finally {
|
|
214
|
+
result.isPinging = false
|
|
215
|
+
runtime.pendingPings = Math.max(0, runtime.pendingPings - 1)
|
|
216
|
+
broadcastUpdate()
|
|
217
|
+
}
|
|
114
218
|
}
|
|
115
219
|
|
|
116
|
-
function
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
220
|
+
function startPingCycle() {
|
|
221
|
+
if (!startedServer?.listening) return
|
|
222
|
+
refreshPingMode()
|
|
223
|
+
|
|
224
|
+
runtime.lastPingTime = Date.now()
|
|
225
|
+
runtime.pingRound += 1
|
|
226
|
+
runtime.nextPingAt = runtime.lastPingTime + runtime.activePingInterval
|
|
227
|
+
|
|
228
|
+
const modelsToPing = results.filter((r) => !r.cliOnly && r.url && isProviderEnabled(config, r.providerKey))
|
|
229
|
+
for (const result of modelsToPing) {
|
|
230
|
+
void pingModel(result)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
broadcastUpdate({ immediate: true })
|
|
234
|
+
scheduleNextPing()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function serializeModel(result) {
|
|
238
|
+
const key = getResultKey(result)
|
|
239
|
+
const avg = getAvg(result)
|
|
240
|
+
const p95 = getP95(result)
|
|
241
|
+
const jitter = getJitter(result)
|
|
242
|
+
const stability = getStabilityScore(result)
|
|
243
|
+
const latest = result.pings.length > 0 ? result.pings[result.pings.length - 1] : null
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
idx: result.idx,
|
|
247
|
+
modelId: result.modelId,
|
|
248
|
+
label: result.label,
|
|
249
|
+
tier: result.tier,
|
|
250
|
+
sweScore: result.sweScore,
|
|
251
|
+
ctx: result.ctx,
|
|
252
|
+
providerKey: result.providerKey,
|
|
253
|
+
origin: result.origin,
|
|
254
|
+
status: result.status,
|
|
255
|
+
httpCode: result.httpCode,
|
|
256
|
+
cliOnly: result.cliOnly,
|
|
257
|
+
zenOnly: result.zenOnly,
|
|
258
|
+
isPinging: result.isPinging,
|
|
259
|
+
avg: Number.isFinite(avg) ? avg : null,
|
|
260
|
+
verdict: getVerdict(result),
|
|
261
|
+
uptime: getUptime(result),
|
|
262
|
+
p95: Number.isFinite(p95) ? p95 : null,
|
|
263
|
+
jitter: Number.isFinite(jitter) ? jitter : null,
|
|
264
|
+
stability,
|
|
265
|
+
latestPing: latest?.ms ?? null,
|
|
266
|
+
latestCode: latest?.code ?? null,
|
|
267
|
+
pingHistory: result.pings.slice(-20).map((p) => ({ ms: p.ms, code: p.code })),
|
|
268
|
+
pingCount: result.pings.length,
|
|
269
|
+
hasApiKey: !!getApiKey(config, result.providerKey),
|
|
270
|
+
benchmarkKey: key,
|
|
271
|
+
isBenchmarking: benchmarkRunning.has(key),
|
|
272
|
+
benchmark: benchmarkResults.get(key) || null,
|
|
124
273
|
}
|
|
125
274
|
}
|
|
126
275
|
|
|
127
276
|
function getModelsPayload() {
|
|
128
|
-
return
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
}))
|
|
277
|
+
return {
|
|
278
|
+
pingMode: runtime.pingMode,
|
|
279
|
+
pingModeSource: runtime.pingModeSource,
|
|
280
|
+
pingInterval: runtime.activePingInterval,
|
|
281
|
+
nextPingAt: runtime.nextPingAt,
|
|
282
|
+
pendingPings: runtime.pendingPings,
|
|
283
|
+
isPinging: runtime.pendingPings > 0,
|
|
284
|
+
pingRound: runtime.pingRound,
|
|
285
|
+
globalBenchmarkRunning: runtime.globalBenchmarkRunning,
|
|
286
|
+
globalBenchmarkTotal: runtime.globalBenchmarkTotal,
|
|
287
|
+
globalBenchmarkCompleted: runtime.globalBenchmarkCompleted,
|
|
288
|
+
models: results.map(serializeModel),
|
|
289
|
+
}
|
|
153
290
|
}
|
|
154
291
|
|
|
155
292
|
function getConfigPayload() {
|
|
156
|
-
// Sanitize — show which providers have keys, but not the actual keys
|
|
157
293
|
const providers = {}
|
|
158
294
|
for (const [key, src] of Object.entries(sources)) {
|
|
159
295
|
const rawKey = getApiKey(config, key)
|
|
@@ -175,7 +311,36 @@ function maskApiKey(key) {
|
|
|
175
311
|
return '••••••••' + key.slice(-4)
|
|
176
312
|
}
|
|
177
313
|
|
|
178
|
-
|
|
314
|
+
function writeSsePayload(res, payload) {
|
|
315
|
+
try {
|
|
316
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`)
|
|
317
|
+
} catch {
|
|
318
|
+
sseClients.delete(res)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function broadcastNow() {
|
|
323
|
+
const payload = getModelsPayload()
|
|
324
|
+
if (io) io.emit('models:update', payload)
|
|
325
|
+
for (const res of [...sseClients]) writeSsePayload(res, payload)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function broadcastUpdate({ immediate = false } = {}) {
|
|
329
|
+
if (immediate) {
|
|
330
|
+
clearTimeout(broadcastTimer)
|
|
331
|
+
broadcastTimer = null
|
|
332
|
+
broadcastNow()
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (broadcastTimer) return
|
|
337
|
+
broadcastTimer = setTimeout(() => {
|
|
338
|
+
broadcastTimer = null
|
|
339
|
+
broadcastNow()
|
|
340
|
+
}, BROADCAST_THROTTLE_MS)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ─── HTTP helpers ───────────────────────────────────────────────────────────
|
|
179
344
|
|
|
180
345
|
const MIME_TYPES = {
|
|
181
346
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -208,7 +373,10 @@ function serveDistFile(res, pathname) {
|
|
|
208
373
|
const ct = MIME_TYPES[ext] || 'application/octet-stream'
|
|
209
374
|
try {
|
|
210
375
|
const content = readFileSync(filePath)
|
|
211
|
-
res.writeHead(200, {
|
|
376
|
+
res.writeHead(200, {
|
|
377
|
+
'Content-Type': ct,
|
|
378
|
+
'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable',
|
|
379
|
+
})
|
|
212
380
|
res.end(content)
|
|
213
381
|
} catch {
|
|
214
382
|
res.writeHead(404)
|
|
@@ -216,10 +384,112 @@ function serveDistFile(res, pathname) {
|
|
|
216
384
|
}
|
|
217
385
|
}
|
|
218
386
|
|
|
219
|
-
function
|
|
220
|
-
res.
|
|
387
|
+
function sendJson(res, status, payload) {
|
|
388
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
|
|
389
|
+
res.end(JSON.stringify(payload))
|
|
390
|
+
}
|
|
221
391
|
|
|
222
|
-
|
|
392
|
+
function readJsonBody(req) {
|
|
393
|
+
return new Promise((resolve, reject) => {
|
|
394
|
+
let body = ''
|
|
395
|
+
req.on('data', (chunk) => {
|
|
396
|
+
body += chunk
|
|
397
|
+
if (body.length > BODY_LIMIT_BYTES) {
|
|
398
|
+
reject(new Error('Request body too large'))
|
|
399
|
+
req.destroy()
|
|
400
|
+
}
|
|
401
|
+
})
|
|
402
|
+
req.on('end', () => {
|
|
403
|
+
if (!body.trim()) {
|
|
404
|
+
resolve({})
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
try { resolve(JSON.parse(body)) }
|
|
408
|
+
catch (err) { reject(err) }
|
|
409
|
+
})
|
|
410
|
+
req.on('error', reject)
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function parseVisibleBenchmarkModels(body) {
|
|
415
|
+
const rawModels = Array.isArray(body?.models) ? body.models : null
|
|
416
|
+
if (!rawModels) return results.filter((r) => !r.cliOnly && r.url)
|
|
417
|
+
|
|
418
|
+
const unique = new Map()
|
|
419
|
+
for (const item of rawModels) {
|
|
420
|
+
if (!item || typeof item !== 'object') continue
|
|
421
|
+
const providerKey = typeof item.providerKey === 'string' ? item.providerKey : ''
|
|
422
|
+
const modelId = typeof item.modelId === 'string' ? item.modelId : ''
|
|
423
|
+
const result = getResult(providerKey, modelId)
|
|
424
|
+
if (result && !result.cliOnly && result.url) unique.set(getResultKey(result), result)
|
|
425
|
+
}
|
|
426
|
+
return [...unique.values()]
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function runSingleBenchmark(result) {
|
|
430
|
+
const key = getResultKey(result)
|
|
431
|
+
if (benchmarkRunning.has(key)) return { skipped: true }
|
|
432
|
+
|
|
433
|
+
benchmarkRunning.add(key)
|
|
434
|
+
broadcastUpdate({ immediate: true })
|
|
435
|
+
try {
|
|
436
|
+
const benchmarkResult = await benchmarkModel({
|
|
437
|
+
apiKey: getApiKey(config, result.providerKey) ?? null,
|
|
438
|
+
modelId: result.modelId,
|
|
439
|
+
providerKey: result.providerKey,
|
|
440
|
+
url: result.url,
|
|
441
|
+
timeoutMs: BENCHMARK_TIMEOUT_MS,
|
|
442
|
+
})
|
|
443
|
+
benchmarkResults.set(key, benchmarkResult)
|
|
444
|
+
updateHealthFromBenchmark(result, benchmarkResult)
|
|
445
|
+
return benchmarkResult
|
|
446
|
+
} catch (err) {
|
|
447
|
+
const fallback = {
|
|
448
|
+
ok: false,
|
|
449
|
+
code: 'ERR',
|
|
450
|
+
totalMs: 0,
|
|
451
|
+
error: err?.message || 'Benchmark failed',
|
|
452
|
+
retries: 0,
|
|
453
|
+
}
|
|
454
|
+
benchmarkResults.set(key, fallback)
|
|
455
|
+
updateHealthFromBenchmark(result, fallback)
|
|
456
|
+
return fallback
|
|
457
|
+
} finally {
|
|
458
|
+
benchmarkRunning.delete(key)
|
|
459
|
+
broadcastUpdate({ immediate: true })
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function runWithConcurrency(tasks, concurrency) {
|
|
464
|
+
return new Promise((resolve) => {
|
|
465
|
+
const resultsOut = new Array(tasks.length)
|
|
466
|
+
let nextIndex = 0
|
|
467
|
+
let active = 0
|
|
468
|
+
let completed = 0
|
|
469
|
+
|
|
470
|
+
function startNext() {
|
|
471
|
+
while (active < concurrency && nextIndex < tasks.length) {
|
|
472
|
+
const index = nextIndex++
|
|
473
|
+
active += 1
|
|
474
|
+
Promise.resolve(tasks[index]())
|
|
475
|
+
.then((value) => { resultsOut[index] = value })
|
|
476
|
+
.catch((err) => { resultsOut[index] = { error: err } })
|
|
477
|
+
.finally(() => {
|
|
478
|
+
active -= 1
|
|
479
|
+
completed += 1
|
|
480
|
+
if (completed >= tasks.length) resolve(resultsOut)
|
|
481
|
+
else startNext()
|
|
482
|
+
})
|
|
483
|
+
}
|
|
484
|
+
if (tasks.length === 0) resolve(resultsOut)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
startNext()
|
|
488
|
+
})
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function handleRequest(req, res) {
|
|
492
|
+
res.setHeader('X-FCM-Server', SERVER_SIGNATURE)
|
|
223
493
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
224
494
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
225
495
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
@@ -230,111 +500,216 @@ function handleRequest(req, res) {
|
|
|
230
500
|
return
|
|
231
501
|
}
|
|
232
502
|
|
|
233
|
-
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
503
|
+
const url = new URL(req.url, `http://${req.headers.host || `localhost:${DEFAULT_WEB_PORT}`}`)
|
|
234
504
|
|
|
235
|
-
// ─── API: Reveal full key for a provider ───
|
|
236
505
|
const keyMatch = url.pathname.match(/^\/api\/key\/(.+)$/)
|
|
237
506
|
if (keyMatch) {
|
|
238
507
|
const providerKey = decodeURIComponent(keyMatch[1])
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
508
|
+
if (!sources[providerKey]) {
|
|
509
|
+
sendJson(res, 404, { error: 'Unknown provider' })
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
sendJson(res, 200, { key: getApiKey(config, providerKey) || null })
|
|
242
513
|
return
|
|
243
514
|
}
|
|
244
515
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
case '/styles.css':
|
|
251
|
-
case '/app.js':
|
|
252
|
-
serveDistFile(res, url.pathname)
|
|
253
|
-
break
|
|
516
|
+
try {
|
|
517
|
+
switch (url.pathname) {
|
|
518
|
+
case '/':
|
|
519
|
+
serveDistFile(res, '/')
|
|
520
|
+
return
|
|
254
521
|
|
|
255
|
-
|
|
256
|
-
|
|
522
|
+
case '/styles.css':
|
|
523
|
+
case '/app.js':
|
|
257
524
|
serveDistFile(res, url.pathname)
|
|
258
|
-
|
|
525
|
+
return
|
|
526
|
+
|
|
527
|
+
case '/api/activity':
|
|
528
|
+
if (req.method !== 'POST') {
|
|
529
|
+
res.writeHead(405)
|
|
530
|
+
res.end('Method Not Allowed')
|
|
531
|
+
return
|
|
532
|
+
}
|
|
533
|
+
noteUserActivity()
|
|
534
|
+
sendJson(res, 200, { ok: true, pingMode: runtime.pingMode })
|
|
535
|
+
return
|
|
536
|
+
|
|
537
|
+
case '/api/ping-mode': {
|
|
538
|
+
if (req.method !== 'POST' && req.method !== 'GET') {
|
|
539
|
+
res.writeHead(405)
|
|
540
|
+
res.end('Method Not Allowed')
|
|
541
|
+
return
|
|
542
|
+
}
|
|
543
|
+
noteUserActivity()
|
|
544
|
+
const action = url.searchParams.get('action')
|
|
545
|
+
if (action === 'cycle') cyclePingMode()
|
|
546
|
+
else if (PING_MODE_INTERVALS[action]) setPingMode(action, 'manual')
|
|
547
|
+
sendJson(res, 200, {
|
|
548
|
+
pingMode: runtime.pingMode,
|
|
549
|
+
pingModeSource: runtime.pingModeSource,
|
|
550
|
+
interval: runtime.activePingInterval,
|
|
551
|
+
nextPingAt: runtime.nextPingAt,
|
|
552
|
+
})
|
|
553
|
+
return
|
|
259
554
|
}
|
|
260
555
|
|
|
261
|
-
|
|
262
|
-
res
|
|
263
|
-
|
|
264
|
-
|
|
556
|
+
case '/api/ping-timer':
|
|
557
|
+
sendJson(res, 200, {
|
|
558
|
+
nextPingAt: runtime.nextPingAt,
|
|
559
|
+
isPinging: runtime.pendingPings > 0,
|
|
560
|
+
pendingPings: runtime.pendingPings,
|
|
561
|
+
})
|
|
562
|
+
return
|
|
563
|
+
|
|
564
|
+
case '/api/models':
|
|
565
|
+
// 📖 Legacy REST contract: keep returning the flat model array.
|
|
566
|
+
sendJson(res, 200, getModelsPayload().models)
|
|
567
|
+
return
|
|
568
|
+
|
|
569
|
+
case '/api/state':
|
|
570
|
+
sendJson(res, 200, getModelsPayload())
|
|
571
|
+
return
|
|
572
|
+
|
|
573
|
+
case '/api/health':
|
|
574
|
+
sendJson(res, 200, { ok: true, app: SERVER_SIGNATURE })
|
|
575
|
+
return
|
|
576
|
+
|
|
577
|
+
case '/api/config':
|
|
578
|
+
sendJson(res, 200, getConfigPayload())
|
|
579
|
+
return
|
|
580
|
+
|
|
581
|
+
case '/api/events':
|
|
582
|
+
res.writeHead(200, {
|
|
583
|
+
'Content-Type': 'text/event-stream',
|
|
584
|
+
'Cache-Control': 'no-cache',
|
|
585
|
+
Connection: 'keep-alive',
|
|
586
|
+
})
|
|
587
|
+
writeSsePayload(res, getModelsPayload())
|
|
588
|
+
sseClients.add(res)
|
|
589
|
+
req.on('close', () => sseClients.delete(res))
|
|
590
|
+
return
|
|
591
|
+
|
|
592
|
+
case '/api/settings': {
|
|
593
|
+
if (req.method !== 'POST') {
|
|
594
|
+
res.writeHead(405)
|
|
595
|
+
res.end('Method Not Allowed')
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
const settings = await readJsonBody(req)
|
|
599
|
+
noteUserActivity()
|
|
600
|
+
if (settings.apiKeys) {
|
|
601
|
+
if (!config.apiKeys) config.apiKeys = {}
|
|
602
|
+
for (const [key, value] of Object.entries(settings.apiKeys)) {
|
|
603
|
+
if (value) config.apiKeys[key] = value
|
|
604
|
+
else delete config.apiKeys[key]
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (settings.providers) {
|
|
608
|
+
if (!config.providers) config.providers = {}
|
|
609
|
+
for (const [key, value] of Object.entries(settings.providers)) {
|
|
610
|
+
if (!config.providers[key]) config.providers[key] = {}
|
|
611
|
+
config.providers[key].enabled = value?.enabled !== false
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
saveConfig(config)
|
|
615
|
+
broadcastUpdate({ immediate: true })
|
|
616
|
+
sendJson(res, 200, { success: true })
|
|
617
|
+
return
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
case '/api/benchmark': {
|
|
621
|
+
if (req.method !== 'POST') {
|
|
622
|
+
res.writeHead(405)
|
|
623
|
+
res.end('Method Not Allowed')
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
const body = await readJsonBody(req)
|
|
627
|
+
const result = getResult(body.providerKey, body.modelId)
|
|
628
|
+
if (!result) {
|
|
629
|
+
sendJson(res, 404, { error: 'Model not found' })
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
const key = getResultKey(result)
|
|
633
|
+
if (benchmarkRunning.has(key)) {
|
|
634
|
+
sendJson(res, 409, { error: 'Benchmark already in progress for this model' })
|
|
635
|
+
return
|
|
636
|
+
}
|
|
637
|
+
noteUserActivity()
|
|
638
|
+
const benchmarkResult = await runSingleBenchmark(result)
|
|
639
|
+
sendJson(res, 200, benchmarkResult)
|
|
640
|
+
return
|
|
265
641
|
}
|
|
266
642
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
643
|
+
case '/api/global-benchmark': {
|
|
644
|
+
if (req.method === 'GET') {
|
|
645
|
+
sendJson(res, 200, {
|
|
646
|
+
running: runtime.globalBenchmarkRunning,
|
|
647
|
+
total: runtime.globalBenchmarkTotal,
|
|
648
|
+
completed: runtime.globalBenchmarkCompleted,
|
|
649
|
+
})
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
if (req.method !== 'POST') {
|
|
653
|
+
res.writeHead(405)
|
|
654
|
+
res.end('Method Not Allowed')
|
|
655
|
+
return
|
|
656
|
+
}
|
|
657
|
+
if (runtime.globalBenchmarkRunning) {
|
|
658
|
+
sendJson(res, 409, { error: 'Global benchmark already running' })
|
|
659
|
+
return
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const body = await readJsonBody(req)
|
|
663
|
+
noteUserActivity()
|
|
664
|
+
const healthPriority = { up: 0, pending: 1, timeout: 2, noauth: 3, auth_error: 4, down: 5 }
|
|
665
|
+
const modelsToBenchmark = parseVisibleBenchmarkModels(body)
|
|
666
|
+
.sort((a, b) => {
|
|
667
|
+
const hpA = healthPriority[a.status] ?? 6
|
|
668
|
+
const hpB = healthPriority[b.status] ?? 6
|
|
669
|
+
if (hpA !== hpB) return hpA - hpB
|
|
670
|
+
const pingA = typeof a.pings?.[a.pings.length - 1]?.ms === 'number' ? a.pings[a.pings.length - 1].ms : 99999
|
|
671
|
+
const pingB = typeof b.pings?.[b.pings.length - 1]?.ms === 'number' ? b.pings[b.pings.length - 1].ms : 99999
|
|
672
|
+
return pingA - pingB
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
runtime.globalBenchmarkRunning = true
|
|
676
|
+
runtime.globalBenchmarkTotal = modelsToBenchmark.length
|
|
677
|
+
runtime.globalBenchmarkCompleted = 0
|
|
678
|
+
broadcastUpdate({ immediate: true })
|
|
679
|
+
|
|
680
|
+
const tasks = modelsToBenchmark.map((model) => async () => {
|
|
299
681
|
try {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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 }))
|
|
682
|
+
return await runSingleBenchmark(model)
|
|
683
|
+
} finally {
|
|
684
|
+
runtime.globalBenchmarkCompleted += 1
|
|
685
|
+
broadcastUpdate({ immediate: true })
|
|
326
686
|
}
|
|
327
687
|
})
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
688
|
+
|
|
689
|
+
void runWithConcurrency(tasks, GLOBAL_BENCHMARK_CONCURRENCY).finally(() => {
|
|
690
|
+
runtime.globalBenchmarkRunning = false
|
|
691
|
+
runtime.globalBenchmarkTotal = 0
|
|
692
|
+
runtime.globalBenchmarkCompleted = 0
|
|
693
|
+
broadcastUpdate({ immediate: true })
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
sendJson(res, 202, { started: true, total: modelsToBenchmark.length })
|
|
697
|
+
return
|
|
331
698
|
}
|
|
332
|
-
|
|
699
|
+
|
|
700
|
+
default:
|
|
701
|
+
if (url.pathname.startsWith('/assets/') || url.pathname.endsWith('.js') || url.pathname.endsWith('.css')) {
|
|
702
|
+
serveDistFile(res, url.pathname)
|
|
703
|
+
return
|
|
704
|
+
}
|
|
705
|
+
res.writeHead(404)
|
|
706
|
+
res.end('Not Found')
|
|
707
|
+
}
|
|
708
|
+
} catch (err) {
|
|
709
|
+
if (!res.writableEnded) sendJson(res, 500, { error: err?.message || 'Internal server error' })
|
|
333
710
|
}
|
|
334
711
|
}
|
|
335
712
|
|
|
336
|
-
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
337
|
-
|
|
338
713
|
function checkPortInUse(port) {
|
|
339
714
|
return new Promise((resolve) => {
|
|
340
715
|
const s = createServer()
|
|
@@ -352,8 +727,6 @@ export async function inspectExistingWebServer(port) {
|
|
|
352
727
|
const timeout = setTimeout(() => controller.abort(), 750)
|
|
353
728
|
|
|
354
729
|
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
730
|
const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
|
358
731
|
signal: controller.signal,
|
|
359
732
|
headers: { Accept: 'application/json' },
|
|
@@ -387,16 +760,13 @@ function openBrowser(url) {
|
|
|
387
760
|
})
|
|
388
761
|
}
|
|
389
762
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
export async function startWebServer(port = 3333, { open = true, startPingLoop = true } = {}) {
|
|
763
|
+
export async function startWebServer(port = DEFAULT_WEB_PORT, { open = true, startPingLoop = true } = {}) {
|
|
393
764
|
const portStatus = await inspectExistingWebServer(port)
|
|
394
765
|
|
|
395
766
|
if (portStatus.inUse && portStatus.isFcm) {
|
|
396
767
|
const url = `http://localhost:${port}`
|
|
397
|
-
|
|
398
768
|
console.log()
|
|
399
|
-
console.log(
|
|
769
|
+
console.log(' ⚡ free-coding-models Web Dashboard already running')
|
|
400
770
|
console.log(` 🌐 ${url}`)
|
|
401
771
|
console.log()
|
|
402
772
|
if (open) openBrowser(url)
|
|
@@ -413,31 +783,56 @@ export async function startWebServer(port = 3333, { open = true, startPingLoop =
|
|
|
413
783
|
}
|
|
414
784
|
|
|
415
785
|
const url = `http://localhost:${resolvedPort}`
|
|
786
|
+
const server = createServer((req, res) => void handleRequest(req, res))
|
|
787
|
+
startedServer = server
|
|
416
788
|
|
|
417
|
-
|
|
418
|
-
|
|
789
|
+
io = new Server(server, {
|
|
790
|
+
cors: { origin: '*', methods: ['GET', 'POST'] },
|
|
791
|
+
transports: ['websocket', 'polling'],
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
io.on('connection', (socket) => {
|
|
795
|
+
noteUserActivity()
|
|
796
|
+
socket.emit('models:update', getModelsPayload())
|
|
797
|
+
socket.on('client:activity', () => noteUserActivity())
|
|
798
|
+
socket.on('models:refresh', () => socket.emit('models:update', getModelsPayload()))
|
|
799
|
+
})
|
|
419
800
|
|
|
420
801
|
server.listen(resolvedPort, () => {
|
|
421
802
|
console.log()
|
|
422
|
-
console.log(
|
|
803
|
+
console.log(' ⚡ free-coding-models Web Dashboard')
|
|
423
804
|
console.log(` 🌐 ${url}`)
|
|
424
|
-
console.log(` 📊 Monitoring ${results.filter(r => !r.cliOnly).length} models across ${Object.keys(sources).length} providers`)
|
|
805
|
+
console.log(` 📊 Monitoring ${results.filter((r) => !r.cliOnly).length} models across ${Object.keys(sources).length} providers`)
|
|
425
806
|
console.log()
|
|
426
|
-
console.log(
|
|
807
|
+
console.log(' Press Ctrl+C to stop')
|
|
427
808
|
console.log()
|
|
809
|
+
if (startPingLoop && !pingLoopTimer) {
|
|
810
|
+
runtime.lastPingTime = Date.now()
|
|
811
|
+
runtime.nextPingAt = runtime.lastPingTime + runtime.activePingInterval
|
|
812
|
+
startPingCycle()
|
|
813
|
+
}
|
|
428
814
|
if (open) openBrowser(url)
|
|
429
815
|
})
|
|
430
816
|
|
|
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
817
|
server.on('close', () => {
|
|
439
|
-
|
|
818
|
+
clearTimeout(pingLoopTimer)
|
|
819
|
+
clearTimeout(broadcastTimer)
|
|
820
|
+
clearInterval(heartbeatTimer)
|
|
821
|
+
for (const res of [...sseClients]) {
|
|
822
|
+
try { res.end() } catch {}
|
|
823
|
+
}
|
|
824
|
+
sseClients.clear()
|
|
825
|
+
io?.close()
|
|
826
|
+
io = null
|
|
827
|
+
if (startedServer === server) startedServer = null
|
|
440
828
|
})
|
|
441
829
|
|
|
830
|
+
heartbeatTimer = setInterval(() => {
|
|
831
|
+
refreshPingMode()
|
|
832
|
+
for (const res of [...sseClients]) {
|
|
833
|
+
try { res.write(': heartbeat\n\n') } catch { sseClients.delete(res) }
|
|
834
|
+
}
|
|
835
|
+
}, 15_000)
|
|
836
|
+
|
|
442
837
|
return server
|
|
443
838
|
}
|