free-coding-models 0.3.55 → 0.3.56
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 +47 -56
- package/README.md +236 -160
- package/bin/free-coding-models.js +46 -0
- package/package.json +2 -2
- package/sources.js +133 -309
- package/src/analysis.js +23 -10
- package/src/app.js +113 -7
- package/src/cache.js +1 -1
- package/src/cli-help.js +9 -0
- package/src/command-palette.js +16 -12
- package/src/config.js +199 -32
- package/src/endpoint-installer.js +45 -1
- package/src/favorites.js +22 -0
- package/src/graphify-out/cache/089db1c1def873cf6d112f1590da4490e61e691aff0db41e006aa2fb15ba0656.json +1 -0
- package/src/graphify-out/cache/0b510b53cf1a1393fb52b1fc3bbbf88b63938e961ec5b82119a2e9715fee8bd7.json +1 -0
- package/src/graphify-out/cache/0ec9a95a326bde58e0316889018b278062d06d494d0f31ba177c9de71e5fed2d.json +1 -0
- package/src/graphify-out/cache/1548663a24a68dce740ebab1bd1d3091048c9604e9d067a1650a42a6d82541d4.json +1 -0
- package/src/graphify-out/cache/1783af63cb6d0dfb4d469009f71ac83a74ba0b33d48186ff2c6e63f9429e900a.json +1 -0
- package/src/graphify-out/cache/1e109f5eb5dc4fd285871c3613e32b6b14a8c225f4080ee34b51c7e1a1764571.json +1 -0
- package/src/graphify-out/cache/1eb24dbeb69b46c8bc1caf925df2f2a964af0f33aea143adf8ddf88e017db6ca.json +1 -0
- package/src/graphify-out/cache/21e1bcfed11685e8347243f9d8516072dda183266a4bfe22c52fb31753a446c8.json +1 -0
- package/src/graphify-out/cache/2327473478b9c4b1940bf7ef66c9ee960b3cba8d5302e56b625df8274246e0b4.json +1 -0
- package/src/graphify-out/cache/25955b81fd25454c8fa90fb71a47db8d1215cf621beb8ff3cbd580aaf011b4f3.json +1 -0
- package/src/graphify-out/cache/2739677f19c702f88f3de0a0bac475066adbda98709907ad3de967aef689f86d.json +1 -0
- package/src/graphify-out/cache/2bba03422f6b3ee7f5b5d29cc90314a064d259e5822a176657bda3e04505cf00.json +1 -0
- package/src/graphify-out/cache/2ddf1d2c6d10147b0402446bc71a7988187b79b6210dd7e7250be8c555b9ff35.json +1 -0
- package/src/graphify-out/cache/2ee07457a5767c95a57f8e9eb95b28f800044f35666e0715e9d88ad1103a092e.json +1 -0
- package/src/graphify-out/cache/2fe9f75dc2951c417f2c8dd22749092cf550dc67599f1c8d1866900dc6e9154e.json +1 -0
- package/src/graphify-out/cache/41c4b7c27e7fc3e2948d3a4bf95a72de2ed9a6f0463994babdce8ed2cc84598c.json +1 -0
- package/src/graphify-out/cache/5028defd54b7fbd3c7e444973e493de036e097e9b1d2a7cae7f19b88d68aacde.json +1 -0
- package/src/graphify-out/cache/5b133aba3fb16410c5b1fdbd1730039fc7fa1ac93abd99d7be08f60da70fc8d4.json +1 -0
- package/src/graphify-out/cache/74252e5b0978d85ab3421a3de1a9384aa282ffd2be2cfe7db2530139089f4275.json +1 -0
- package/src/graphify-out/cache/7695ebeea056095edd14332963cc43354ef3a097caf46f1e28d0f01369642901.json +1 -0
- package/src/graphify-out/cache/777aa7085c395a935c6556bbde182cd871edb61f3a685ed8068ec0c8f6fb0075.json +1 -0
- package/src/graphify-out/cache/82a723881980e82273c113def8315533d7da28827e300413d9ad30f27b7407df.json +1 -0
- package/src/graphify-out/cache/86b87c9603e6cd188f42c7eed3b86c291d48a781c223a707e74f3e7ed0c02a21.json +1 -0
- package/src/graphify-out/cache/890fead9a78cadaed560a2d2453916121fa605c3e43a334910ac4bc951a9ef6d.json +1 -0
- package/src/graphify-out/cache/89d3ea66f52783caa775ef9a30923d7d6225e1d8ae9e962f4741b8c7785dab1e.json +1 -0
- package/src/graphify-out/cache/8cc82cd9edce41f0e1c092f14a94fd52bf847addf3237b616dc5a9e505bd05bd.json +1 -0
- package/src/graphify-out/cache/93ba2e25e3ff7ad525f397902345fbd375df7315de7b402e20cc803c14eccde8.json +1 -0
- package/src/graphify-out/cache/99beed29580b9c7bfecfee794cb3d8e535fcf0eb3b92113108f88bdd0a8e79b3.json +1 -0
- package/src/graphify-out/cache/aeeb931fa477c65ce2e51d8149957350fa54225c613222bbbe8448998d1afd3d.json +1 -0
- package/src/graphify-out/cache/baf91bef5b5ecb2a476433b6cc0c48c563c54ee2d07fc3c192e543685e3e7222.json +1 -0
- package/src/graphify-out/cache/bd98b94ac4e9b92b6336d47b26e0366b51a4eaf0711d722f05f98dfae23ab42b.json +1 -0
- package/src/graphify-out/cache/bfcb51e9328e9cbfbee4f6fee0f56635d7b03488addc9f6c4e4b190b70a73362.json +1 -0
- package/src/graphify-out/cache/c0d3dabeb093aa758c49eadf41b87ecc96a16c1449c2670aaf48cbfc891d8da6.json +1 -0
- package/src/graphify-out/cache/c20d6630236f473c1406068c3ae205853e649b216495c93dfec055dd222c55cf.json +1 -0
- package/src/graphify-out/cache/c22b9122816bebce0a2f79af41a986559d01e00163dbcd579c5755621b4cb483.json +1 -0
- package/src/graphify-out/cache/ca556ec14453ddb8f9e0c5a832dac90d77111b9bad5f8c2d80d272e2e7a06371.json +1 -0
- package/src/graphify-out/cache/d6dbc9135dfa35a756b3b09b06700e4bc229fdccba11bb963f2ba44028e0bbae.json +1 -0
- package/src/graphify-out/cache/e1cf71276f1779d0fa075f79bd7c8a9fd0b8eef6932ac043137451b7c7fa7cbe.json +1 -0
- package/src/graphify-out/cache/e4b3be14494467df2d2ed389bc4f18f099021cb5bc355b901fa88387b2d8b8a2.json +1 -0
- package/src/graphify-out/cache/eaea0dded097f6f9553b654220046c6ec0c9be592a5973d906564ee60af34e0d.json +1 -0
- package/src/graphify-out/cache/ef07d0cd2675d1f79d2a2fdbf3bc3319687638751e9ce89b0d0d97ed1cd9f7e1.json +1 -0
- package/src/graphify-out/cache/f81272d6eb8aaff9e96d5a1d9f06777db70ac3652a646b951ded51f79871d733.json +1 -0
- package/src/graphify-out/cache/f9619dd92186f75a6dbda937e0c606647153918524cdb5763f956e6ec2a9e386.json +1 -0
- package/src/graphify-out/cache/fd88b1b2ff4bfcae08559d9c2aaeeb9a3f1e2f5cd8928762c311196956c170a5.json +1 -0
- package/src/key-handler.js +312 -12
- package/src/kilo.js +20 -1
- package/src/opencode.js +23 -2
- package/src/overlays.js +206 -5
- package/src/provider-metadata.js +26 -17
- package/src/quota-capabilities.js +6 -10
- package/src/render-table.js +37 -4
- package/src/router-daemon.js +1986 -0
- package/src/router-dashboard.js +893 -0
- package/src/sync-set.js +479 -0
- package/src/theme.js +4 -0
- package/src/tool-launchers.js +1 -0
- package/src/tool-metadata.js +6 -2
- package/src/utils.js +30 -6
- package/web/dist/assets/{index-C03JjCgA.js → index-DNRCaWPi.js} +2 -2
- package/web/dist/index.html +1 -1
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file router-dashboard.js
|
|
3
|
+
* @description TUI client, SSE reader, and renderer for the Smart Model Router dashboard.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* 📖 This module is intentionally defensive: the dashboard talks to a local
|
|
7
|
+
* daemon that may be stopped, stale, mid-restart, or returning malformed JSON.
|
|
8
|
+
* Every public helper treats unexpected payloads as partial data instead of
|
|
9
|
+
* throwing into the render loop.
|
|
10
|
+
*
|
|
11
|
+
* The dashboard uses polling for baseline reliability and an SSE stream for
|
|
12
|
+
* low-latency request/probe/circuit updates. Polling keeps the screen useful
|
|
13
|
+
* when `/stream/events` is unavailable, while SSE keeps the request log live.
|
|
14
|
+
*
|
|
15
|
+
* @functions
|
|
16
|
+
* → `openRouterDashboardOverlay` — Start polling/SSE and mark the overlay open
|
|
17
|
+
* → `closeRouterDashboardOverlay` — Close the overlay and stop dashboard I/O
|
|
18
|
+
* → `refreshRouterDashboardSnapshot` — Fetch `/health` + `/stats` safely
|
|
19
|
+
* → `startRouterDashboardEventStream` — Connect to `/stream/events`
|
|
20
|
+
* → `cycleRouterDashboardActiveSet` — Activate the next daemon set
|
|
21
|
+
* → `cycleRouterDashboardProbeMode` — Rotate eco/balanced/aggressive mode
|
|
22
|
+
* → `renderRouterDashboard` — Render the full-screen dashboard overlay
|
|
23
|
+
* → `normalizeRouterDashboardSnapshot` — Sanitize daemon payloads for rendering
|
|
24
|
+
* → `parseRouterDashboardSseFrame` — Parse one SSE frame
|
|
25
|
+
*
|
|
26
|
+
* @exports openRouterDashboardOverlay, closeRouterDashboardOverlay
|
|
27
|
+
* @exports refreshRouterDashboardSnapshot, startRouterDashboardEventStream
|
|
28
|
+
* @exports cycleRouterDashboardActiveSet, cycleRouterDashboardProbeMode
|
|
29
|
+
* @exports clearRouterDashboardRequestLog, restartRouterDashboardDaemon
|
|
30
|
+
* @exports toggleRouterDashboardProbePause, stopRouterDashboardClient
|
|
31
|
+
* @exports renderRouterDashboard, normalizeRouterDashboardSnapshot
|
|
32
|
+
* @exports parseRouterDashboardSseFrame, formatRouterDuration
|
|
33
|
+
* @exports fetchRouterSets, createRouterSet, renameRouterSet, duplicateRouterSet
|
|
34
|
+
* @exports deleteRouterSet, activateRouterSet, updateRouterSetModels
|
|
35
|
+
* @exports addModelToRouterSet, removeModelFromRouterSet, reorderRouterSetModel
|
|
36
|
+
*
|
|
37
|
+
* @see ./router-daemon.js — daemon endpoints consumed by this screen
|
|
38
|
+
* @see ./overlays.js — overlay factory that mounts this renderer
|
|
39
|
+
* @see ./key-handler.js — dashboard key bindings
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
43
|
+
import { displayWidth, padEndDisplay, sliceOverlayLines, tintOverlayLines } from './render-helpers.js'
|
|
44
|
+
import { ROUTER_DEFAULT_PORT, ROUTER_MAX_PORT, ROUTER_PID_PATH, ROUTER_PORT_PATH, getRouterPortRange } from './router-daemon.js'
|
|
45
|
+
import { themeColors } from './theme.js'
|
|
46
|
+
import { formatTokenTotalCompact } from './token-usage-reader.js'
|
|
47
|
+
import { sendUsageTelemetry } from './telemetry.js'
|
|
48
|
+
|
|
49
|
+
export const ROUTER_DASHBOARD_POLL_INTERVAL_MS = 2000
|
|
50
|
+
export const ROUTER_DASHBOARD_FETCH_TIMEOUT_MS = 1200
|
|
51
|
+
export const ROUTER_PROBE_MODE_CYCLE = ['eco', 'balanced', 'aggressive']
|
|
52
|
+
|
|
53
|
+
const ROUTER_DASHBOARD_EVENT_LIMIT = 80
|
|
54
|
+
const ROUTER_DASHBOARD_REQUEST_LIMIT = 30
|
|
55
|
+
|
|
56
|
+
function isRecord(value) {
|
|
57
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function toFiniteNumber(value, fallback = null) {
|
|
61
|
+
const numeric = Number(value)
|
|
62
|
+
return Number.isFinite(numeric) ? numeric : fallback
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function safeString(value, fallback = '—') {
|
|
66
|
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
67
|
+
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
|
68
|
+
return fallback
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function compactText(value, width) {
|
|
72
|
+
const text = safeString(value, '')
|
|
73
|
+
if (displayWidth(text) <= width) return padEndDisplay(text, width)
|
|
74
|
+
const plain = text.replace(/\s+/g, ' ')
|
|
75
|
+
let out = ''
|
|
76
|
+
for (const char of plain) {
|
|
77
|
+
if (displayWidth(`${out}${char}…`) > width) break
|
|
78
|
+
out += char
|
|
79
|
+
}
|
|
80
|
+
return padEndDisplay(`${out}…`, width)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readNumberFile(path) {
|
|
84
|
+
try {
|
|
85
|
+
const value = Number.parseInt(readFileSync(path, 'utf8').trim(), 10)
|
|
86
|
+
return Number.isFinite(value) ? value : null
|
|
87
|
+
} catch {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isProcessAlive(pid) {
|
|
93
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
94
|
+
try {
|
|
95
|
+
process.kill(pid, 0)
|
|
96
|
+
return true
|
|
97
|
+
} catch {
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function makeTimeoutController(ms) {
|
|
103
|
+
const controller = new AbortController()
|
|
104
|
+
const timer = setTimeout(() => controller.abort(), ms)
|
|
105
|
+
return {
|
|
106
|
+
signal: controller.signal,
|
|
107
|
+
clear: () => clearTimeout(timer),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function fetchJson(url, options = {}) {
|
|
112
|
+
const {
|
|
113
|
+
method = 'GET',
|
|
114
|
+
body = null,
|
|
115
|
+
fetchFn = globalThis.fetch,
|
|
116
|
+
timeoutMs = ROUTER_DASHBOARD_FETCH_TIMEOUT_MS,
|
|
117
|
+
} = options
|
|
118
|
+
if (typeof fetchFn !== 'function') {
|
|
119
|
+
return { ok: false, status: 0, data: null, error: 'fetch is not available in this Node runtime' }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const timeout = makeTimeoutController(timeoutMs)
|
|
123
|
+
try {
|
|
124
|
+
const response = await fetchFn(url, {
|
|
125
|
+
method,
|
|
126
|
+
body,
|
|
127
|
+
headers: body ? { 'content-type': 'application/json' } : undefined,
|
|
128
|
+
signal: timeout.signal,
|
|
129
|
+
})
|
|
130
|
+
const raw = await response.text()
|
|
131
|
+
let data = {}
|
|
132
|
+
try {
|
|
133
|
+
data = raw.trim() ? JSON.parse(raw) : {}
|
|
134
|
+
} catch {
|
|
135
|
+
return { ok: false, status: response.status, data: null, error: `Malformed JSON from ${url}` }
|
|
136
|
+
}
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
const message = data?.error?.message || data?.error?.code || `HTTP ${response.status}`
|
|
139
|
+
return { ok: false, status: response.status, data, error: message }
|
|
140
|
+
}
|
|
141
|
+
return { ok: true, status: response.status, data, error: null }
|
|
142
|
+
} catch (error) {
|
|
143
|
+
const message = error?.name === 'AbortError' ? 'request timed out' : (error?.message || String(error))
|
|
144
|
+
return { ok: false, status: 0, data: null, error: message }
|
|
145
|
+
} finally {
|
|
146
|
+
timeout.clear()
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function readDaemonFiles() {
|
|
151
|
+
const recordedPort = readNumberFile(ROUTER_PORT_PATH)
|
|
152
|
+
const recordedPid = readNumberFile(ROUTER_PID_PATH)
|
|
153
|
+
return {
|
|
154
|
+
port: recordedPort,
|
|
155
|
+
pid: recordedPid,
|
|
156
|
+
pidAlive: recordedPid ? isProcessAlive(recordedPid) : false,
|
|
157
|
+
hasPidFile: existsSync(ROUTER_PID_PATH),
|
|
158
|
+
hasPortFile: existsSync(ROUTER_PORT_PATH),
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildPortCandidates(state) {
|
|
163
|
+
const ports = []
|
|
164
|
+
const currentPort = Number.parseInt(String(state.routerDashboardPort || ''), 10)
|
|
165
|
+
const baseUrlMatch = typeof state.routerDashboardBaseUrl === 'string'
|
|
166
|
+
? state.routerDashboardBaseUrl.match(/:(\d+)$/)
|
|
167
|
+
: null
|
|
168
|
+
const baseUrlPort = baseUrlMatch ? Number.parseInt(baseUrlMatch[1], 10) : null
|
|
169
|
+
const filePort = readNumberFile(ROUTER_PORT_PATH)
|
|
170
|
+
const { defaultPort, maxPort } = getRouterPortRange()
|
|
171
|
+
for (const port of [baseUrlPort, currentPort, filePort, defaultPort]) {
|
|
172
|
+
if (Number.isInteger(port) && port > 0 && !ports.includes(port)) ports.push(port)
|
|
173
|
+
}
|
|
174
|
+
for (let port = defaultPort; port <= maxPort; port += 1) {
|
|
175
|
+
if (!ports.includes(port)) ports.push(port)
|
|
176
|
+
}
|
|
177
|
+
return ports
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function discoverRouterDashboard(state, fetchFn = globalThis.fetch) {
|
|
181
|
+
let lastError = null
|
|
182
|
+
for (const port of buildPortCandidates(state)) {
|
|
183
|
+
const baseUrl = `http://127.0.0.1:${port}`
|
|
184
|
+
const health = await fetchJson(`${baseUrl}/health`, { fetchFn })
|
|
185
|
+
if (health.ok) return { baseUrl, port, health: health.data, error: null }
|
|
186
|
+
lastError = health.error
|
|
187
|
+
}
|
|
188
|
+
const files = readDaemonFiles()
|
|
189
|
+
return {
|
|
190
|
+
baseUrl: null,
|
|
191
|
+
port: files.port,
|
|
192
|
+
health: {
|
|
193
|
+
ok: false,
|
|
194
|
+
running: false,
|
|
195
|
+
pid: files.pid,
|
|
196
|
+
port: files.port,
|
|
197
|
+
stalePid: files.pid && !files.pidAlive ? files.pid : null,
|
|
198
|
+
},
|
|
199
|
+
error: lastError || 'Router daemon is not reachable',
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeTokens(tokens) {
|
|
204
|
+
const today = isRecord(tokens?.today) ? tokens.today : {}
|
|
205
|
+
const allTime = isRecord(tokens?.all_time) ? tokens.all_time : {}
|
|
206
|
+
return {
|
|
207
|
+
today: {
|
|
208
|
+
total_tokens: toFiniteNumber(today.total_tokens, 0),
|
|
209
|
+
prompt_tokens: toFiniteNumber(today.prompt_tokens, 0),
|
|
210
|
+
completion_tokens: toFiniteNumber(today.completion_tokens, 0),
|
|
211
|
+
requests: toFiniteNumber(today.requests, 0),
|
|
212
|
+
by_model: isRecord(today.by_model) ? today.by_model : {},
|
|
213
|
+
},
|
|
214
|
+
all_time: {
|
|
215
|
+
total_tokens: toFiniteNumber(allTime.total_tokens, 0),
|
|
216
|
+
prompt_tokens: toFiniteNumber(allTime.prompt_tokens, 0),
|
|
217
|
+
completion_tokens: toFiniteNumber(allTime.completion_tokens, 0),
|
|
218
|
+
requests: toFiniteNumber(allTime.requests, 0),
|
|
219
|
+
first_tracked: safeString(allTime.first_tracked, null),
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function normalizeModelHealth(entry, index) {
|
|
225
|
+
const model = isRecord(entry) ? entry : {}
|
|
226
|
+
return {
|
|
227
|
+
priority: toFiniteNumber(model.priority, index + 1),
|
|
228
|
+
provider: safeString(model.provider, 'unknown'),
|
|
229
|
+
model: safeString(model.model, 'unknown'),
|
|
230
|
+
key: safeString(model.key, `${safeString(model.provider, 'unknown')}/${safeString(model.model, 'unknown')}`),
|
|
231
|
+
state: safeString(model.state, 'UNKNOWN').toUpperCase(),
|
|
232
|
+
score: toFiniteNumber(model.score, null),
|
|
233
|
+
last_latency_ms: toFiniteNumber(model.last_latency_ms, null),
|
|
234
|
+
uptime: toFiniteNumber(model.uptime, null),
|
|
235
|
+
last_error: safeString(model.last_error, null),
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function normalizeRequestEntry(entry) {
|
|
240
|
+
const item = isRecord(entry) ? entry : {}
|
|
241
|
+
return {
|
|
242
|
+
at: safeString(item.at, new Date().toISOString()),
|
|
243
|
+
request_id: safeString(item.request_id, ''),
|
|
244
|
+
model: safeString(item.model, '—'),
|
|
245
|
+
status: item.status ?? '—',
|
|
246
|
+
latency_ms: toFiniteNumber(item.latency_ms, null),
|
|
247
|
+
tokens: toFiniteNumber(item.tokens, 0),
|
|
248
|
+
failover: item.failover === true,
|
|
249
|
+
stream: item.stream === true,
|
|
250
|
+
error: safeString(item.error, null),
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function normalizeRouterDashboardSnapshot(healthPayload, statsPayload) {
|
|
255
|
+
const health = isRecord(healthPayload) ? healthPayload : {}
|
|
256
|
+
const stats = isRecord(statsPayload) ? statsPayload : {}
|
|
257
|
+
const merged = { ...health, ...stats }
|
|
258
|
+
const models = Array.isArray(stats.models) ? stats.models.map(normalizeModelHealth) : []
|
|
259
|
+
const requestLog = Array.isArray(stats.requestLog) ? stats.requestLog.map(normalizeRequestEntry) : []
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
ok: merged.ok === true,
|
|
263
|
+
pid: toFiniteNumber(merged.pid, null),
|
|
264
|
+
port: toFiniteNumber(merged.port, null),
|
|
265
|
+
enabled: merged.enabled === true,
|
|
266
|
+
activeSet: safeString(merged.activeSet, '—'),
|
|
267
|
+
activeModelCount: toFiniteNumber(merged.activeModelCount, models.length),
|
|
268
|
+
setCount: toFiniteNumber(merged.setCount, 0),
|
|
269
|
+
uptimeSeconds: toFiniteNumber(merged.uptimeSeconds, 0),
|
|
270
|
+
requestsRouted: toFiniteNumber(merged.requestsRouted, 0),
|
|
271
|
+
inFlight: toFiniteNumber(merged.inFlight, 0),
|
|
272
|
+
shuttingDown: merged.shuttingDown === true,
|
|
273
|
+
probeMode: safeString(merged.probeMode, 'unknown'),
|
|
274
|
+
lastProbeAt: safeString(merged.lastProbeAt, null),
|
|
275
|
+
crashRecovered: toFiniteNumber(merged.crashRecovered, 0),
|
|
276
|
+
configPath: safeString(merged.configPath, ''),
|
|
277
|
+
tokenStatsPath: safeString(merged.tokenStatsPath, ''),
|
|
278
|
+
logPath: safeString(merged.logPath, ''),
|
|
279
|
+
stalePid: toFiniteNumber(merged.stalePid, null),
|
|
280
|
+
tokens: normalizeTokens(stats.tokens),
|
|
281
|
+
models,
|
|
282
|
+
requestLog,
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function formatRouterDuration(seconds) {
|
|
287
|
+
const total = Math.max(0, Math.floor(Number(seconds) || 0))
|
|
288
|
+
const days = Math.floor(total / 86400)
|
|
289
|
+
const hours = Math.floor((total % 86400) / 3600)
|
|
290
|
+
const minutes = Math.floor((total % 3600) / 60)
|
|
291
|
+
const secs = total % 60
|
|
292
|
+
if (days > 0) return `${days}d ${hours}h`
|
|
293
|
+
if (hours > 0) return `${hours}h ${minutes}m`
|
|
294
|
+
if (minutes > 0) return `${minutes}m ${secs}s`
|
|
295
|
+
return `${secs}s`
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function formatAge(iso) {
|
|
299
|
+
if (!iso) return 'never'
|
|
300
|
+
const then = Date.parse(iso)
|
|
301
|
+
if (!Number.isFinite(then)) return 'unknown'
|
|
302
|
+
const seconds = Math.max(0, Math.floor((Date.now() - then) / 1000))
|
|
303
|
+
return `${formatRouterDuration(seconds)} ago`
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function formatPercent(value) {
|
|
307
|
+
if (!Number.isFinite(value)) return '—'
|
|
308
|
+
return `${Math.round(value * 100)}%`
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function modelStateBadge(state) {
|
|
312
|
+
const label = safeString(state, 'UNKNOWN').toUpperCase()
|
|
313
|
+
if (label === 'CLOSED') return themeColors.success('● CLOSED')
|
|
314
|
+
if (label === 'HALF_OPEN') return themeColors.warning('◐ HALF')
|
|
315
|
+
if (label === 'OPEN') return themeColors.error('○ OPEN')
|
|
316
|
+
if (label === 'AUTH_ERROR') return themeColors.error('⚠ AUTH')
|
|
317
|
+
if (label === 'STALE') return themeColors.dim('💀 STALE')
|
|
318
|
+
if (label === 'UNSUPPORTED') return themeColors.dim('× UNSUP')
|
|
319
|
+
return themeColors.dim(`? ${label}`)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function statusBadge(status, snapshot) {
|
|
323
|
+
if (status === 'ready') return themeColors.successBold('● RUNNING')
|
|
324
|
+
if (status === 'partial') return themeColors.warningBold('◐ PARTIAL')
|
|
325
|
+
if (status === 'loading') return themeColors.warning('◌ LOADING')
|
|
326
|
+
if (status === 'stale' || snapshot.stalePid) return themeColors.errorBold('○ STALE PID')
|
|
327
|
+
if (status === 'malformed') return themeColors.errorBold('○ BAD JSON')
|
|
328
|
+
if (status === 'stopped') return themeColors.dim('○ STOPPED')
|
|
329
|
+
return themeColors.error('○ UNREACHABLE')
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function setDashboardNotice(state, type, message, ttlMs = 3500) {
|
|
333
|
+
state.routerDashboardNotice = { type, message, at: Date.now() }
|
|
334
|
+
if (state.routerDashboardNoticeTimer) clearTimeout(state.routerDashboardNoticeTimer)
|
|
335
|
+
state.routerDashboardNoticeTimer = setTimeout(() => {
|
|
336
|
+
if (state.routerDashboardNotice?.message === message) state.routerDashboardNotice = null
|
|
337
|
+
}, ttlMs)
|
|
338
|
+
state.routerDashboardNoticeTimer.unref?.()
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function parseRouterDashboardSseFrame(frame) {
|
|
342
|
+
const lines = String(frame || '').split(/\r?\n/)
|
|
343
|
+
let event = 'message'
|
|
344
|
+
const dataLines = []
|
|
345
|
+
for (const line of lines) {
|
|
346
|
+
if (line.startsWith('event:')) event = line.slice(6).trim() || 'message'
|
|
347
|
+
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart())
|
|
348
|
+
}
|
|
349
|
+
const rawData = dataLines.join('\n')
|
|
350
|
+
let data = null
|
|
351
|
+
if (rawData) {
|
|
352
|
+
try {
|
|
353
|
+
data = JSON.parse(rawData)
|
|
354
|
+
} catch {
|
|
355
|
+
data = rawData
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return { event, data }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function appendRouterDashboardEvent(state, event, data) {
|
|
362
|
+
if (!Array.isArray(state.routerDashboardEvents)) state.routerDashboardEvents = []
|
|
363
|
+
const entry = { event, data, at: new Date().toISOString() }
|
|
364
|
+
state.routerDashboardEvents.unshift(entry)
|
|
365
|
+
while (state.routerDashboardEvents.length > ROUTER_DASHBOARD_EVENT_LIMIT) state.routerDashboardEvents.pop()
|
|
366
|
+
|
|
367
|
+
if (event === 'request') {
|
|
368
|
+
if (!Array.isArray(state.routerDashboardLiveRequests)) state.routerDashboardLiveRequests = []
|
|
369
|
+
state.routerDashboardLiveRequests.unshift(normalizeRequestEntry({ ...(isRecord(data) ? data : {}), at: entry.at }))
|
|
370
|
+
while (state.routerDashboardLiveRequests.length > ROUTER_DASHBOARD_REQUEST_LIMIT) state.routerDashboardLiveRequests.pop()
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export async function refreshRouterDashboardSnapshot(state, options = {}) {
|
|
375
|
+
const fetchFn = options.fetchFn || globalThis.fetch
|
|
376
|
+
if (!state.routerDashboardOpen && !options.force) return null
|
|
377
|
+
state.routerDashboardLastRefreshStartedAt = Date.now()
|
|
378
|
+
if (!state.routerDashboardStatus || state.routerDashboardStatus === 'idle') {
|
|
379
|
+
state.routerDashboardStatus = 'loading'
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
let discovery
|
|
383
|
+
try {
|
|
384
|
+
discovery = await discoverRouterDashboard(state, fetchFn)
|
|
385
|
+
} catch (err) {
|
|
386
|
+
// 📖 Guard: if discovery itself throws (e.g. fetchFn not callable), degrade gracefully
|
|
387
|
+
state.routerDashboardStatus = 'unreachable'
|
|
388
|
+
state.routerDashboardError = err?.message || 'Discovery failed unexpectedly'
|
|
389
|
+
state.routerDashboardLastUpdatedAt = Date.now()
|
|
390
|
+
return normalizeRouterDashboardSnapshot(null, null)
|
|
391
|
+
}
|
|
392
|
+
if (!discovery.baseUrl) {
|
|
393
|
+
state.routerDashboardBaseUrl = null
|
|
394
|
+
state.routerDashboardPort = discovery.port
|
|
395
|
+
state.routerDashboardHealth = discovery.health
|
|
396
|
+
state.routerDashboardStats = null
|
|
397
|
+
const files = readDaemonFiles()
|
|
398
|
+
state.routerDashboardStatus = discovery.health?.stalePid
|
|
399
|
+
? 'stale'
|
|
400
|
+
: files.hasPidFile || files.hasPortFile
|
|
401
|
+
? 'unreachable'
|
|
402
|
+
: 'stopped'
|
|
403
|
+
state.routerDashboardError = discovery.error
|
|
404
|
+
state.routerDashboardLastUpdatedAt = Date.now()
|
|
405
|
+
stopRouterDashboardEventStream(state)
|
|
406
|
+
return normalizeRouterDashboardSnapshot(state.routerDashboardHealth, null)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
state.routerDashboardBaseUrl = discovery.baseUrl
|
|
410
|
+
state.routerDashboardPort = discovery.port
|
|
411
|
+
state.routerDashboardHealth = discovery.health
|
|
412
|
+
const stats = await fetchJson(`${discovery.baseUrl}/stats`, { fetchFn })
|
|
413
|
+
if (!stats.ok) {
|
|
414
|
+
state.routerDashboardStats = null
|
|
415
|
+
state.routerDashboardStatus = stats.error?.includes('Malformed JSON') ? 'malformed' : 'partial'
|
|
416
|
+
state.routerDashboardError = stats.error
|
|
417
|
+
state.routerDashboardLastUpdatedAt = Date.now()
|
|
418
|
+
startRouterDashboardEventStream(state, { fetchFn })
|
|
419
|
+
return normalizeRouterDashboardSnapshot(state.routerDashboardHealth, null)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
state.routerDashboardStats = stats.data
|
|
423
|
+
state.routerDashboardStatus = 'ready'
|
|
424
|
+
state.routerDashboardError = null
|
|
425
|
+
state.routerDashboardLastUpdatedAt = Date.now()
|
|
426
|
+
startRouterDashboardEventStream(state, { fetchFn })
|
|
427
|
+
return normalizeRouterDashboardSnapshot(state.routerDashboardHealth, state.routerDashboardStats)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function startRouterDashboardPolling(state, options = {}) {
|
|
431
|
+
if (state.routerDashboardPollTimer) return
|
|
432
|
+
const fetchFn = options.fetchFn || globalThis.fetch
|
|
433
|
+
void refreshRouterDashboardSnapshot(state, { fetchFn, force: true })
|
|
434
|
+
state.routerDashboardPollTimer = setInterval(() => {
|
|
435
|
+
void refreshRouterDashboardSnapshot(state, { fetchFn, force: true })
|
|
436
|
+
}, ROUTER_DASHBOARD_POLL_INTERVAL_MS)
|
|
437
|
+
state.routerDashboardPollTimer.unref?.()
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function stopRouterDashboardEventStream(state) {
|
|
441
|
+
if (state.routerDashboardEventAbort) {
|
|
442
|
+
try { state.routerDashboardEventAbort.abort() } catch {}
|
|
443
|
+
}
|
|
444
|
+
state.routerDashboardEventAbort = null
|
|
445
|
+
if (state.routerDashboardEventStatus === 'connected' || state.routerDashboardEventStatus === 'connecting') {
|
|
446
|
+
state.routerDashboardEventStatus = 'idle'
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function stopRouterDashboardClient(state) {
|
|
451
|
+
if (state.routerDashboardPollTimer) clearInterval(state.routerDashboardPollTimer)
|
|
452
|
+
state.routerDashboardPollTimer = null
|
|
453
|
+
stopRouterDashboardEventStream(state)
|
|
454
|
+
if (state.routerDashboardNoticeTimer) clearTimeout(state.routerDashboardNoticeTimer)
|
|
455
|
+
state.routerDashboardNoticeTimer = null
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function startRouterDashboardEventStream(state, options = {}) {
|
|
459
|
+
const fetchFn = options.fetchFn || globalThis.fetch
|
|
460
|
+
if (!state.routerDashboardOpen) return
|
|
461
|
+
if (!state.routerDashboardBaseUrl || typeof fetchFn !== 'function') return
|
|
462
|
+
if (state.routerDashboardEventAbort) return
|
|
463
|
+
|
|
464
|
+
const controller = new AbortController()
|
|
465
|
+
state.routerDashboardEventAbort = controller
|
|
466
|
+
state.routerDashboardEventStatus = 'connecting'
|
|
467
|
+
state.routerDashboardEventError = null
|
|
468
|
+
|
|
469
|
+
void (async () => {
|
|
470
|
+
try {
|
|
471
|
+
const response = await fetchFn(`${state.routerDashboardBaseUrl}/stream/events`, {
|
|
472
|
+
headers: { accept: 'text/event-stream' },
|
|
473
|
+
signal: controller.signal,
|
|
474
|
+
})
|
|
475
|
+
if (!response.ok) throw new Error(`SSE HTTP ${response.status}`)
|
|
476
|
+
if (!response.body || typeof response.body.getReader !== 'function') throw new Error('SSE body is not readable')
|
|
477
|
+
state.routerDashboardEventStatus = 'connected'
|
|
478
|
+
const reader = response.body.getReader()
|
|
479
|
+
const decoder = new TextDecoder()
|
|
480
|
+
let buffer = ''
|
|
481
|
+
while (!controller.signal.aborted) {
|
|
482
|
+
const chunk = await reader.read()
|
|
483
|
+
if (chunk.done) break
|
|
484
|
+
buffer += decoder.decode(chunk.value, { stream: true })
|
|
485
|
+
let frameEnd = buffer.indexOf('\n\n')
|
|
486
|
+
while (frameEnd >= 0) {
|
|
487
|
+
const frame = buffer.slice(0, frameEnd)
|
|
488
|
+
buffer = buffer.slice(frameEnd + 2)
|
|
489
|
+
const parsed = parseRouterDashboardSseFrame(frame)
|
|
490
|
+
appendRouterDashboardEvent(state, parsed.event, parsed.data)
|
|
491
|
+
frameEnd = buffer.indexOf('\n\n')
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
} catch (error) {
|
|
495
|
+
if (!controller.signal.aborted) {
|
|
496
|
+
state.routerDashboardEventStatus = 'offline'
|
|
497
|
+
state.routerDashboardEventError = error?.message || String(error)
|
|
498
|
+
}
|
|
499
|
+
} finally {
|
|
500
|
+
if (state.routerDashboardEventAbort === controller) state.routerDashboardEventAbort = null
|
|
501
|
+
}
|
|
502
|
+
})()
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function openRouterDashboardOverlay(state) {
|
|
506
|
+
state.routerDashboardOpen = true
|
|
507
|
+
state.routerDashboardScrollOffset = 0
|
|
508
|
+
state.routerDashboardStatus = state.routerDashboardStatus || 'loading'
|
|
509
|
+
startRouterDashboardPolling(state)
|
|
510
|
+
// 📖 Fire app_router_install on first Shift+R dashboard open for upgrade-path users
|
|
511
|
+
if (!state.routerDashboardEverOpened && state.config?.router) {
|
|
512
|
+
state.routerDashboardEverOpened = true
|
|
513
|
+
void sendUsageTelemetry(state.config, {}, {
|
|
514
|
+
event: 'app_router_install',
|
|
515
|
+
mode: 'dashboard',
|
|
516
|
+
properties: { router_version: '0.4.0', trigger: 'upgrade_path' },
|
|
517
|
+
})
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export function closeRouterDashboardOverlay(state) {
|
|
522
|
+
state.routerDashboardOpen = false
|
|
523
|
+
state.routerDashboardScrollOffset = 0
|
|
524
|
+
stopRouterDashboardClient(state)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export async function cycleRouterDashboardActiveSet(state, options = {}) {
|
|
528
|
+
const fetchFn = options.fetchFn || globalThis.fetch
|
|
529
|
+
const baseUrl = state.routerDashboardBaseUrl
|
|
530
|
+
if (!baseUrl) {
|
|
531
|
+
setDashboardNotice(state, 'error', 'Router daemon is not reachable; cannot switch sets.')
|
|
532
|
+
return
|
|
533
|
+
}
|
|
534
|
+
const response = await fetchJson(`${baseUrl}/sets`, { fetchFn })
|
|
535
|
+
if (!response.ok || !isRecord(response.data?.sets)) {
|
|
536
|
+
setDashboardNotice(state, 'error', `Could not load router sets: ${response.error || 'unexpected payload'}`)
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
const setNames = Object.keys(response.data.sets)
|
|
540
|
+
if (setNames.length <= 1) {
|
|
541
|
+
setDashboardNotice(state, 'info', 'Only one router set exists right now.')
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
const active = safeString(response.data.activeSet, setNames[0])
|
|
545
|
+
const activeIdx = Math.max(0, setNames.indexOf(active))
|
|
546
|
+
const nextName = setNames[(activeIdx + 1) % setNames.length]
|
|
547
|
+
const activate = await fetchJson(`${baseUrl}/sets/${encodeURIComponent(nextName)}/activate`, {
|
|
548
|
+
method: 'POST',
|
|
549
|
+
fetchFn,
|
|
550
|
+
})
|
|
551
|
+
if (!activate.ok) {
|
|
552
|
+
setDashboardNotice(state, 'error', `Could not activate ${nextName}: ${activate.error}`)
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
setDashboardNotice(state, 'success', `Active router set: ${nextName}`)
|
|
556
|
+
await refreshRouterDashboardSnapshot(state, { fetchFn, force: true })
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export async function cycleRouterDashboardProbeMode(state, options = {}) {
|
|
560
|
+
const fetchFn = options.fetchFn || globalThis.fetch
|
|
561
|
+
const baseUrl = state.routerDashboardBaseUrl
|
|
562
|
+
if (!baseUrl) {
|
|
563
|
+
setDashboardNotice(state, 'error', 'Router daemon is not reachable; cannot change probe mode.')
|
|
564
|
+
return
|
|
565
|
+
}
|
|
566
|
+
const snapshot = normalizeRouterDashboardSnapshot(state.routerDashboardHealth, state.routerDashboardStats)
|
|
567
|
+
const currentIndex = ROUTER_PROBE_MODE_CYCLE.indexOf(snapshot.probeMode)
|
|
568
|
+
const nextMode = ROUTER_PROBE_MODE_CYCLE[(currentIndex >= 0 ? currentIndex + 1 : 0) % ROUTER_PROBE_MODE_CYCLE.length]
|
|
569
|
+
const response = await fetchJson(`${baseUrl}/daemon/probe-mode`, {
|
|
570
|
+
method: 'POST',
|
|
571
|
+
body: JSON.stringify({ probeMode: nextMode }),
|
|
572
|
+
fetchFn,
|
|
573
|
+
})
|
|
574
|
+
if (!response.ok) {
|
|
575
|
+
setDashboardNotice(state, 'error', `Probe mode change failed: ${response.error}`)
|
|
576
|
+
return
|
|
577
|
+
}
|
|
578
|
+
setDashboardNotice(state, 'success', `Probe mode: ${nextMode}`)
|
|
579
|
+
await refreshRouterDashboardSnapshot(state, { fetchFn, force: true })
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function clearRouterDashboardRequestLog(state) {
|
|
583
|
+
state.routerDashboardClearedAt = Date.now()
|
|
584
|
+
state.routerDashboardLiveRequests = []
|
|
585
|
+
state.routerDashboardEvents = []
|
|
586
|
+
setDashboardNotice(state, 'success', 'Local dashboard request log cleared.')
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function restartRouterDashboardDaemon(state) {
|
|
590
|
+
setDashboardNotice(state, 'info', 'Restart is reserved for Phase 7 service-manager support.')
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export function toggleRouterDashboardProbePause(state) {
|
|
594
|
+
setDashboardNotice(state, 'info', 'Probe pause/resume needs backend support and remains disabled for now.')
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ── Set Manager helpers (Phase 4) ──────────────────────────────────────────────
|
|
598
|
+
|
|
599
|
+
const SETS_FETCH_INTERVAL_MS = 5000
|
|
600
|
+
|
|
601
|
+
function isSetsDataStale(lastFetchAt) {
|
|
602
|
+
return Date.now() - lastFetchAt > SETS_FETCH_INTERVAL_MS
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export async function fetchRouterSets(state, options = {}) {
|
|
606
|
+
const fetchFn = options.fetchFn || globalThis.fetch
|
|
607
|
+
const baseUrl = state.routerDashboardBaseUrl
|
|
608
|
+
if (!baseUrl) {
|
|
609
|
+
return { ok: false, error: 'Daemon not reachable', sets: null }
|
|
610
|
+
}
|
|
611
|
+
const result = await fetchJson(`${baseUrl}/sets`, { fetchFn })
|
|
612
|
+
if (!result.ok) return { ok: false, error: result.error, sets: null }
|
|
613
|
+
const payload = result.data
|
|
614
|
+
if (!isRecord(payload) || !isRecord(payload.sets)) {
|
|
615
|
+
return { ok: false, error: 'Unexpected /sets payload', sets: null }
|
|
616
|
+
}
|
|
617
|
+
state.setsData = payload
|
|
618
|
+
state.setsLastFetchAt = Date.now()
|
|
619
|
+
return { ok: true, error: null, sets: payload }
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export async function createRouterSet(state, name, options = {}) {
|
|
623
|
+
const fetchFn = options.fetchFn || globalThis.fetch
|
|
624
|
+
const baseUrl = state.routerDashboardBaseUrl
|
|
625
|
+
if (!baseUrl) return { ok: false, error: 'Daemon not reachable' }
|
|
626
|
+
const result = await fetchJson(`${baseUrl}/sets`, {
|
|
627
|
+
method: 'POST',
|
|
628
|
+
body: JSON.stringify({ name, models: [] }),
|
|
629
|
+
fetchFn,
|
|
630
|
+
})
|
|
631
|
+
if (!result.ok) return { ok: false, error: result.error }
|
|
632
|
+
await fetchRouterSets(state, { fetchFn })
|
|
633
|
+
return { ok: true }
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export async function renameRouterSet(state, oldName, newName, options = {}) {
|
|
637
|
+
const fetchFn = options.fetchFn || globalThis.fetch
|
|
638
|
+
const baseUrl = state.routerDashboardBaseUrl
|
|
639
|
+
if (!baseUrl) return { ok: false, error: 'Daemon not reachable' }
|
|
640
|
+
const result = await fetchJson(`${baseUrl}/sets/${encodeURIComponent(oldName)}`, {
|
|
641
|
+
method: 'PUT',
|
|
642
|
+
body: JSON.stringify({ name: newName }),
|
|
643
|
+
fetchFn,
|
|
644
|
+
})
|
|
645
|
+
if (!result.ok) return { ok: false, error: result.error }
|
|
646
|
+
await fetchRouterSets(state, { fetchFn })
|
|
647
|
+
return { ok: true }
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export async function duplicateRouterSet(state, sourceName, newName, options = {}) {
|
|
651
|
+
const fetchFn = options.fetchFn || globalThis.fetch
|
|
652
|
+
const baseUrl = state.routerDashboardBaseUrl
|
|
653
|
+
if (!baseUrl) return { ok: false, error: 'Daemon not reachable' }
|
|
654
|
+
const sets = state.setsData?.sets
|
|
655
|
+
if (!sets || !sets[sourceName]) return { ok: false, error: 'Source set not found' }
|
|
656
|
+
const models = sets[sourceName].models || []
|
|
657
|
+
const result = await fetchJson(`${baseUrl}/sets`, {
|
|
658
|
+
method: 'POST',
|
|
659
|
+
body: JSON.stringify({ name: newName, models }),
|
|
660
|
+
fetchFn,
|
|
661
|
+
})
|
|
662
|
+
if (!result.ok) return { ok: false, error: result.error }
|
|
663
|
+
await fetchRouterSets(state, { fetchFn })
|
|
664
|
+
return { ok: true }
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export async function deleteRouterSet(state, name, options = {}) {
|
|
668
|
+
const fetchFn = options.fetchFn || globalThis.fetch
|
|
669
|
+
const baseUrl = state.routerDashboardBaseUrl
|
|
670
|
+
if (!baseUrl) return { ok: false, error: 'Daemon not reachable' }
|
|
671
|
+
const result = await fetchJson(`${baseUrl}/sets/${encodeURIComponent(name)}`, {
|
|
672
|
+
method: 'DELETE',
|
|
673
|
+
fetchFn,
|
|
674
|
+
})
|
|
675
|
+
if (!result.ok) return { ok: false, error: result.error }
|
|
676
|
+
await fetchRouterSets(state, { fetchFn })
|
|
677
|
+
return { ok: true }
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export async function activateRouterSet(state, name, options = {}) {
|
|
681
|
+
const fetchFn = options.fetchFn || globalThis.fetch
|
|
682
|
+
const baseUrl = state.routerDashboardBaseUrl
|
|
683
|
+
if (!baseUrl) return { ok: false, error: 'Daemon not reachable' }
|
|
684
|
+
const result = await fetchJson(`${baseUrl}/sets/${encodeURIComponent(name)}/activate`, {
|
|
685
|
+
method: 'POST',
|
|
686
|
+
fetchFn,
|
|
687
|
+
})
|
|
688
|
+
if (!result.ok) return { ok: false, error: result.error }
|
|
689
|
+
await fetchRouterSets(state, { fetchFn })
|
|
690
|
+
return { ok: true }
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
export async function updateRouterSetModels(state, setName, models, options = {}) {
|
|
694
|
+
const fetchFn = options.fetchFn || globalThis.fetch
|
|
695
|
+
const baseUrl = state.routerDashboardBaseUrl
|
|
696
|
+
if (!baseUrl) return { ok: false, error: 'Daemon not reachable' }
|
|
697
|
+
const result = await fetchJson(`${baseUrl}/sets/${encodeURIComponent(setName)}`, {
|
|
698
|
+
method: 'PUT',
|
|
699
|
+
body: JSON.stringify({ models }),
|
|
700
|
+
fetchFn,
|
|
701
|
+
})
|
|
702
|
+
if (!result.ok) return { ok: false, error: result.error }
|
|
703
|
+
await fetchRouterSets(state, { fetchFn })
|
|
704
|
+
return { ok: true }
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export async function addModelToRouterSet(state, setName, provider, model, priority, options = {}) {
|
|
708
|
+
const fetchFn = options.fetchFn || globalThis.fetch
|
|
709
|
+
const baseUrl = state.routerDashboardBaseUrl
|
|
710
|
+
if (!baseUrl) return { ok: false, error: 'Daemon not reachable' }
|
|
711
|
+
const sets = state.setsData?.sets
|
|
712
|
+
if (!sets || !sets[setName]) return { ok: false, error: 'Set not found' }
|
|
713
|
+
const currentModels = sets[setName].models || []
|
|
714
|
+
const modelEntry = { provider, model, priority: Number(priority) || currentModels.length + 1 }
|
|
715
|
+
const result = await updateRouterSetModels(state, setName, [...currentModels, modelEntry], { fetchFn })
|
|
716
|
+
return result
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export async function removeModelFromRouterSet(state, setName, provider, model, options = {}) {
|
|
720
|
+
const fetchFn = options.fetchFn || globalThis.fetch
|
|
721
|
+
const baseUrl = state.routerDashboardBaseUrl
|
|
722
|
+
if (!baseUrl) return { ok: false, error: 'Daemon not reachable' }
|
|
723
|
+
const sets = state.setsData?.sets
|
|
724
|
+
if (!sets || !sets[setName]) return { ok: false, error: 'Set not found' }
|
|
725
|
+
const currentModels = (sets[setName].models || []).filter(
|
|
726
|
+
(m) => !(m.provider === provider && m.model === model)
|
|
727
|
+
)
|
|
728
|
+
return updateRouterSetModels(state, setName, currentModels, { fetchFn })
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
export async function reorderRouterSetModel(state, setName, provider, model, direction, options = {}) {
|
|
732
|
+
const fetchFn = options.fetchFn || globalThis.fetch
|
|
733
|
+
const baseUrl = state.routerDashboardBaseUrl
|
|
734
|
+
if (!baseUrl) return { ok: false, error: 'Daemon not reachable' }
|
|
735
|
+
const sets = state.setsData?.sets
|
|
736
|
+
if (!sets || !sets[setName]) return { ok: false, error: 'Set not found' }
|
|
737
|
+
const currentModels = [...(sets[setName].models || [])]
|
|
738
|
+
const idx = currentModels.findIndex((m) => m.provider === provider && m.model === model)
|
|
739
|
+
if (idx < 0) return { ok: false, error: 'Model not in set' }
|
|
740
|
+
const newIdx = direction === 'up' ? idx - 1 : idx + 1
|
|
741
|
+
if (newIdx < 0 || newIdx >= currentModels.length) return { ok: false, error: 'Already at edge' }
|
|
742
|
+
const [moved] = currentModels.splice(idx, 1)
|
|
743
|
+
currentModels.splice(newIdx, 0, moved)
|
|
744
|
+
for (let i = 0; i < currentModels.length; i++) {
|
|
745
|
+
currentModels[i] = { ...currentModels[i], priority: i + 1 }
|
|
746
|
+
}
|
|
747
|
+
return updateRouterSetModels(state, setName, currentModels, { fetchFn })
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function requestLogRows(state, snapshot) {
|
|
751
|
+
const clearedAt = Number(state.routerDashboardClearedAt || 0)
|
|
752
|
+
const candidates = [
|
|
753
|
+
...(Array.isArray(state.routerDashboardLiveRequests) ? state.routerDashboardLiveRequests : []),
|
|
754
|
+
...snapshot.requestLog,
|
|
755
|
+
]
|
|
756
|
+
const seen = new Set()
|
|
757
|
+
return candidates
|
|
758
|
+
.filter((entry) => {
|
|
759
|
+
const at = Date.parse(entry.at)
|
|
760
|
+
return !Number.isFinite(at) || at >= clearedAt
|
|
761
|
+
})
|
|
762
|
+
.filter((entry) => {
|
|
763
|
+
const key = entry.request_id || `${entry.at}:${entry.model}:${entry.status}:${entry.latency_ms}`
|
|
764
|
+
if (seen.has(key)) return false
|
|
765
|
+
seen.add(key)
|
|
766
|
+
return true
|
|
767
|
+
})
|
|
768
|
+
.slice(0, 10)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function topTokenModel(tokens) {
|
|
772
|
+
const byModel = tokens.today.by_model
|
|
773
|
+
let bestKey = null
|
|
774
|
+
let bestTotal = 0
|
|
775
|
+
for (const [key, value] of Object.entries(byModel || {})) {
|
|
776
|
+
const total = toFiniteNumber(isRecord(value) ? value.total : value, 0)
|
|
777
|
+
if (total > bestTotal) {
|
|
778
|
+
bestKey = key
|
|
779
|
+
bestTotal = total
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return bestKey ? `${bestKey} (${formatTokenTotalCompact(bestTotal)})` : '—'
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function renderNotice(notice) {
|
|
786
|
+
if (!notice?.message) return null
|
|
787
|
+
const color = notice.type === 'error'
|
|
788
|
+
? themeColors.errorBold
|
|
789
|
+
: notice.type === 'success'
|
|
790
|
+
? themeColors.successBold
|
|
791
|
+
: themeColors.warningBold
|
|
792
|
+
return ` ${color(notice.message)}`
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export function renderRouterDashboard(state, deps = {}) {
|
|
796
|
+
const LOCAL_VERSION = deps.LOCAL_VERSION || ''
|
|
797
|
+
const EL = '\x1b[K'
|
|
798
|
+
const lines = []
|
|
799
|
+
const snapshot = normalizeRouterDashboardSnapshot(state.routerDashboardHealth, state.routerDashboardStats)
|
|
800
|
+
const status = state.routerDashboardStatus || 'idle'
|
|
801
|
+
const width = Math.max(80, state.terminalCols || 80)
|
|
802
|
+
const separator = themeColors.dim('─'.repeat(Math.max(20, width - 6)))
|
|
803
|
+
const requestRows = requestLogRows(state, snapshot)
|
|
804
|
+
const eventStatus = state.routerDashboardEventStatus || 'idle'
|
|
805
|
+
const updatedAt = state.routerDashboardLastUpdatedAt
|
|
806
|
+
? new Date(state.routerDashboardLastUpdatedAt).toLocaleTimeString()
|
|
807
|
+
: 'never'
|
|
808
|
+
|
|
809
|
+
lines.push(` ${themeColors.accent('🚀')} ${themeColors.accentBold('free-coding-models')} ${themeColors.dim(LOCAL_VERSION ? `v${LOCAL_VERSION}` : '')}`)
|
|
810
|
+
lines.push(` ${themeColors.textBold('🔀 FCM Router Dashboard')} ${themeColors.dim('Shift+R from main table')}`)
|
|
811
|
+
lines.push('')
|
|
812
|
+
lines.push(` Daemon: ${statusBadge(status, snapshot)} ${themeColors.dim('Port:')} ${themeColors.info(String(snapshot.port || state.routerDashboardPort || '—'))} ${themeColors.dim('PID:')} ${snapshot.pid || '—'} ${themeColors.dim('SSE:')} ${eventStatus}`)
|
|
813
|
+
lines.push(` Set: ${themeColors.textBold(snapshot.activeSet)} ${themeColors.dim('Models:')} ${snapshot.activeModelCount} ${themeColors.dim('Uptime:')} ${formatRouterDuration(snapshot.uptimeSeconds)} ${themeColors.dim('Requests:')} ${snapshot.requestsRouted} ${themeColors.dim('In flight:')} ${snapshot.inFlight}`)
|
|
814
|
+
lines.push(` Probes: ${themeColors.info(snapshot.probeMode)} ${themeColors.dim('Last probe:')} ${formatAge(snapshot.lastProbeAt)} ${themeColors.dim('Last refresh:')} ${updatedAt}`)
|
|
815
|
+
if (state.routerDashboardError) lines.push(` ${themeColors.error(`Dashboard note: ${state.routerDashboardError}`)}`)
|
|
816
|
+
if (state.routerDashboardEventError) lines.push(` ${themeColors.warning(`Event stream: ${state.routerDashboardEventError}`)}`)
|
|
817
|
+
const notice = renderNotice(state.routerDashboardNotice)
|
|
818
|
+
if (notice) lines.push(notice)
|
|
819
|
+
if (snapshot.setCount === 0) {
|
|
820
|
+
lines.push(` ${themeColors.warningBold('⚠ No router sets found. Press Y to install providers into FCM Router, then restart the daemon.')}`)
|
|
821
|
+
}
|
|
822
|
+
lines.push(` ${separator}`)
|
|
823
|
+
lines.push('')
|
|
824
|
+
|
|
825
|
+
lines.push(` ${themeColors.textBold('Model Health / Circuit Breakers')}`)
|
|
826
|
+
if (snapshot.models.length === 0) {
|
|
827
|
+
lines.push(` ${themeColors.dim('No model health rows available yet. Start the daemon or wait for /stats to answer.')}`)
|
|
828
|
+
} else {
|
|
829
|
+
const header = ` ${padEndDisplay('#', 4)} ${padEndDisplay('Provider', 12)} ${padEndDisplay('Model', 30)} ${padEndDisplay('State', 12)} ${padEndDisplay('P95', 8)} ${padEndDisplay('Up', 5)} Score`
|
|
830
|
+
lines.push(themeColors.dim(header))
|
|
831
|
+
for (const model of snapshot.models.slice(0, 12)) {
|
|
832
|
+
const latency = Number.isFinite(model.last_latency_ms) ? `${Math.round(model.last_latency_ms)}ms` : '—'
|
|
833
|
+
const score = Number.isFinite(model.score) ? model.score.toFixed(2) : '—'
|
|
834
|
+
const errorSuffix = model.last_error ? themeColors.dim(` ${compactText(model.last_error, 22).trimEnd()}`) : ''
|
|
835
|
+
lines.push(
|
|
836
|
+
` ${padEndDisplay(String(model.priority), 4)} ` +
|
|
837
|
+
`${compactText(model.provider, 12)} ` +
|
|
838
|
+
`${compactText(model.model, 30)} ` +
|
|
839
|
+
`${padEndDisplay(modelStateBadge(model.state), 12)} ` +
|
|
840
|
+
`${padEndDisplay(latency, 8)} ` +
|
|
841
|
+
`${padEndDisplay(formatPercent(model.uptime), 5)} ` +
|
|
842
|
+
`${score}${errorSuffix}`
|
|
843
|
+
)
|
|
844
|
+
}
|
|
845
|
+
if (snapshot.models.length > 12) lines.push(themeColors.dim(` … ${snapshot.models.length - 12} more models in active set`))
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
lines.push('')
|
|
849
|
+
lines.push(` ${themeColors.textBold('Token Summary')}`)
|
|
850
|
+
lines.push(` Today: ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.today.total_tokens))} tok ${themeColors.dim('Requests:')} ${snapshot.tokens.today.requests} ${themeColors.dim('Prompt/Completion:')} ${formatTokenTotalCompact(snapshot.tokens.today.prompt_tokens)} / ${formatTokenTotalCompact(snapshot.tokens.today.completion_tokens)}`)
|
|
851
|
+
lines.push(` All-time: ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.all_time.total_tokens))} tok ${themeColors.dim('Requests:')} ${snapshot.tokens.all_time.requests} ${themeColors.dim('Top today:')} ${compactText(topTokenModel(snapshot.tokens), Math.max(18, width - 58)).trimEnd()}`)
|
|
852
|
+
|
|
853
|
+
lines.push('')
|
|
854
|
+
lines.push(` ${themeColors.textBold('Live Request Log')}`)
|
|
855
|
+
if (requestRows.length === 0) {
|
|
856
|
+
lines.push(` ${themeColors.dim('No routed requests in the local dashboard log yet.')}`)
|
|
857
|
+
} else {
|
|
858
|
+
const header = ` ${padEndDisplay('Time', 10)} ${padEndDisplay('Model', 34)} ${padEndDisplay('Status', 8)} ${padEndDisplay('Latency', 9)} ${padEndDisplay('Tokens', 9)} Detail`
|
|
859
|
+
lines.push(themeColors.dim(header))
|
|
860
|
+
for (const row of requestRows) {
|
|
861
|
+
const atMs = Date.parse(row.at)
|
|
862
|
+
const time = Number.isFinite(atMs) ? new Date(atMs).toLocaleTimeString() : '—'
|
|
863
|
+
const statusText = String(row.status)
|
|
864
|
+
const statusColor = statusText.startsWith('2') ? themeColors.success : statusText === 'ERR' ? themeColors.error : themeColors.warning
|
|
865
|
+
const latency = Number.isFinite(row.latency_ms) ? `${Math.round(row.latency_ms)}ms` : '—'
|
|
866
|
+
const detail = [
|
|
867
|
+
row.failover ? 'failover' : '',
|
|
868
|
+
row.stream ? 'stream' : '',
|
|
869
|
+
row.error || '',
|
|
870
|
+
].filter(Boolean).join(', ') || '—'
|
|
871
|
+
lines.push(
|
|
872
|
+
` ${padEndDisplay(time, 10)} ` +
|
|
873
|
+
`${compactText(row.model, 34)} ` +
|
|
874
|
+
`${padEndDisplay(statusColor(statusText), 8)} ` +
|
|
875
|
+
`${padEndDisplay(latency, 9)} ` +
|
|
876
|
+
`${padEndDisplay(formatTokenTotalCompact(row.tokens), 9)} ` +
|
|
877
|
+
`${compactText(detail, Math.max(10, width - 78)).trimEnd()}`
|
|
878
|
+
)
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
lines.push('')
|
|
883
|
+
const lastEvent = Array.isArray(state.routerDashboardEvents) && state.routerDashboardEvents[0]
|
|
884
|
+
? `${state.routerDashboardEvents[0].event} @ ${new Date(state.routerDashboardEvents[0].at).toLocaleTimeString()}`
|
|
885
|
+
: 'none'
|
|
886
|
+
lines.push(` ${themeColors.dim(`Endpoint: ${state.routerDashboardBaseUrl || 'not connected'} • Last SSE event: ${lastEvent}`)}`)
|
|
887
|
+
lines.push(` ${themeColors.hotkey('S')} ${themeColors.dim('Switch set')} ${themeColors.dim('•')} ${themeColors.hotkey('I')} ${themeColors.dim('Probe mode')} ${themeColors.dim('•')} ${themeColors.hotkey('R')} ${themeColors.dim('Restart (Phase 7)')} ${themeColors.dim('•')} ${themeColors.hotkey('C')} ${themeColors.dim('Clear log')} ${themeColors.dim('•')} ${themeColors.hotkey('P')} ${themeColors.dim('Pause probes (disabled)')} ${themeColors.dim('•')} ${themeColors.hotkey('Esc')} ${themeColors.dim('Back')}`)
|
|
888
|
+
|
|
889
|
+
const { visible, offset } = sliceOverlayLines(lines, state.routerDashboardScrollOffset || 0, state.terminalRows || 24)
|
|
890
|
+
state.routerDashboardScrollOffset = offset
|
|
891
|
+
const tinted = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols || 80)
|
|
892
|
+
return tinted.map((line) => line + EL).join('\n')
|
|
893
|
+
}
|