Skip to content

Commit 8c17d92

Browse files
committed
0.5.1 - ⚡ Realtime Web Dashboard
1 parent 67dd489 commit 8c17d92

26 files changed

Lines changed: 1371 additions & 666 deletions

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,15 @@ docker run -p 19280:19280 ghcr.io/vava-nessa/free-coding-models:latest
145145
docker run -p 19280:19280 -e OPENROUTER_API_KEY=your_key ghcr.io/vava-nessa/free-coding-models:latest
146146
```
147147
148-
Access the web dashboard at `http://localhost:19280/` and configure your coding tool to use `http://localhost:19280/v1` with model `fcm`.
148+
Access the daemon web dashboard at `http://localhost:19280/` and configure your coding tool to use `http://localhost:19280/v1` with model `fcm`.
149+
150+
For the full TUI-style catalog dashboard from an npm install, run:
151+
152+
```bash
153+
free-coding-models web
154+
```
155+
156+
This starts the realtime Web Dashboard locally, opens it in your browser, and uses `http://localhost:3333/` by default. Override the port with `FCM_WEB_PORT=3334 free-coding-models web`.
149157

150158
### Available Image Tags
151159

bin/free-coding-models.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ async function main() {
5353
process.exit(0);
5454
}
5555

56+
// 📖 Standalone web dashboard: same full-catalog ping UI as the TUI, served
57+
// 📖 locally with Socket.IO/SSE/REST realtime updates.
58+
if (cliArgs.webMode) {
59+
const { startWebServer } = await import('../web/server.js');
60+
const parsedPort = Number.parseInt(process.env.FCM_WEB_PORT || process.env.FCM_PORT || '3333', 10);
61+
const port = Number.isFinite(parsedPort) && parsedPort > 0 ? parsedPort : 3333;
62+
await startWebServer(port, { open: true, startPingLoop: true });
63+
return;
64+
}
65+
5666
// 📖 Router daemon lifecycle flags run before the TUI so automation and
5767
// 📖 editor integrations can manage the local OpenAI-compatible endpoint.
5868
if (cliArgs.daemonMode || cliArgs.daemonBackgroundMode || cliArgs.daemonStopMode || cliArgs.daemonStatusMode) {

changelog/v0.5.1.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Changelog v0.5.1 - 2026-05-31
2+
3+
### Added
4+
- Added `free-coding-models web` as the npm-friendly way to launch the full TUI-style realtime Web Dashboard from a global install.
5+
- Added Socket.IO as the primary dashboard realtime transport, with SSE and REST polling fallbacks so the UI keeps updating even when one transport is unavailable.
6+
- Added `/api/state` support for dashboard clients and daemon dashboard clients, including ping mode, countdown, per-model ping state, and benchmark progress metadata.
7+
- Added tests that lock every visible web table column as sortable, including display-only columns such as ``, Last Ping, AI Latency, TPS, and Trend.
8+
9+
### Changed
10+
- Reworked the standalone web server to mirror the TUI ping cadence more closely: startup speed mode, normal cadence, idle slow mode, per-model `isPinging`, and frequent incremental updates.
11+
- Changed the Web Dashboard AI Latency global benchmark to benchmark only the models currently visible after filters and search, instead of always benchmarking the full catalog.
12+
- Made every dashboard table column cycle through ascending, descending, and reset sorting, with missing values consistently pushed to the bottom.
13+
- Removed the top stats card row from the dashboard for a cleaner, table-first layout.
14+
- Removed the `ms` suffix from Last Ping and Avg cells in the dashboard table to make dense latency columns easier to scan.
15+
- Improved dashboard table borders and added visible column separators, including stronger light-theme borders.
16+
- Updated npm and web documentation to distinguish the full catalog dashboard (`free-coding-models web`, default `localhost:3333`) from the router daemon dashboard (`free-coding-models --daemon`, default `localhost:19280`).
17+
18+
### Fixed
19+
- Fixed `free-coding-models web` being parsed as an API key by treating `web` as a real subcommand.
20+
- Fixed dashboard benchmark spinners so only the actively benchmarked row shows running state instead of making unrelated rows spin during global scans.
21+
- Fixed benchmark result keys by using provider/model identifiers, avoiding collisions and invisible results when providers share model ids.
22+
- Fixed the Tier “All” filter mismatch in the Web Dashboard.
23+
- Fixed light-theme button contrast where accent buttons could render unreadable black-on-black text.
24+
- Fixed the web development readiness check so it waits for the correct local server response.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "free-coding-models",
3-
"version": "0.5.0",
3+
"version": "0.5.1",
44
"description": "Find the fastest coding LLM models in seconds — ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
55
"keywords": [
66
"nvidia",

scripts/dev-web.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ startWebServer(${API_PORT}, { open: false, startPingLoop: true }).then(() => {})
6161
let portReady = false
6262
for (let i = 0; i < 30; i++) {
6363
await new Promise(r => setTimeout(r, 300))
64-
portReady = !(await isPortUsed(API_PORT))
64+
portReady = await isPortUsed(API_PORT)
6565
if (portReady) break
6666
}
6767

src/core/router-daemon.js

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
saveConfig,
4949
} from './config.js'
5050
import { buildChatCompletionPingBody, resolveCloudflareUrl, shouldUseDisabledThinkingForProvider } from './ping.js'
51+
import { benchmarkModel, BENCHMARK_TIMEOUT_MS } from './benchmark.js'
5152
import { sendUsageTelemetry } from './telemetry.js'
5253

5354
export const ROUTER_DEFAULT_PORT = 19280
@@ -343,12 +344,32 @@ function getWebModelsPayload(runtime) {
343344
pingCount: pings.length,
344345
hasApiKey,
345346
inRouterSet: inSetIndex.has(`${providerKey}::${modelId}`),
347+
benchmarkKey: key,
348+
isBenchmarking: runtime.webBenchmarkRunning?.has(key) || false,
349+
benchmark: runtime.webBenchmarkResults?.get(key) || null,
346350
})
347351
}
348352
}
349353
return payload
350354
}
351355

356+
function getWebStatePayload(runtime) {
357+
const router = runtime.routerConfig()
358+
const probeInterval = router.probeIntervals?.[router.probeMode] || DEFAULT_ROUTER_SETTINGS.probeIntervals.balanced
359+
return {
360+
pingMode: router.probeMode === 'aggressive' ? 'speed' : router.probeMode === 'eco' ? 'slow' : 'normal',
361+
pingModeSource: 'daemon-probe-mode',
362+
pingInterval: probeInterval,
363+
nextPingAt: runtime.lastProbeAt ? runtime.lastProbeAt + probeInterval : null,
364+
pendingPings: runtime.probeTimeouts?.size || 0,
365+
isPinging: (runtime.probeTimeouts?.size || 0) > 0,
366+
globalBenchmarkRunning: runtime.webGlobalBenchmarkRunning || false,
367+
globalBenchmarkTotal: runtime.webGlobalBenchmarkTotal || 0,
368+
globalBenchmarkCompleted: runtime.webGlobalBenchmarkCompleted || 0,
369+
models: getWebModelsPayload(runtime),
370+
}
371+
}
372+
352373
function getWebConfigPayload(runtime) {
353374
const providers = {}
354375
for (const [key, src] of Object.entries(sources)) {
@@ -786,6 +807,11 @@ class RouterRuntime {
786807
this.quotaExhausted = new Set()
787808
this.quotaDetails = new Map()
788809
this.staleNotifications = new Set()
810+
this.webBenchmarkRunning = new Set()
811+
this.webBenchmarkResults = new Map()
812+
this.webGlobalBenchmarkRunning = false
813+
this.webGlobalBenchmarkTotal = 0
814+
this.webGlobalBenchmarkCompleted = 0
789815
this.refreshRouteState()
790816
}
791817

@@ -1131,6 +1157,99 @@ class RouterRuntime {
11311157
}
11321158
}
11331159

1160+
broadcastWebState() {
1161+
this.broadcast('models', getWebStatePayload(this))
1162+
}
1163+
1164+
async runWebBenchmark(providerKey, modelId) {
1165+
const key = modelKey(providerKey, modelId)
1166+
if (this.webBenchmarkRunning.has(key)) return { skipped: true }
1167+
const source = sources[providerKey]
1168+
if (!source?.url) {
1169+
return { ok: false, code: 'UNSUPPORTED', totalMs: 0, error: 'Provider has no benchmark URL', retries: 0 }
1170+
}
1171+
1172+
this.webBenchmarkRunning.add(key)
1173+
this.broadcastWebState()
1174+
try {
1175+
const result = await benchmarkModel({
1176+
apiKey: this.getApiKeyForProvider(providerKey) ?? null,
1177+
modelId,
1178+
providerKey,
1179+
url: source.url,
1180+
timeoutMs: BENCHMARK_TIMEOUT_MS,
1181+
})
1182+
this.webBenchmarkResults.set(key, result)
1183+
return result
1184+
} catch (err) {
1185+
const fallback = { ok: false, code: 'ERR', totalMs: 0, error: err?.message || 'Benchmark failed', retries: 0 }
1186+
this.webBenchmarkResults.set(key, fallback)
1187+
return fallback
1188+
} finally {
1189+
this.webBenchmarkRunning.delete(key)
1190+
this.broadcastWebState()
1191+
}
1192+
}
1193+
1194+
async runWebGlobalBenchmark(models) {
1195+
if (this.webGlobalBenchmarkRunning) return { started: false, error: 'Global benchmark already running' }
1196+
const knownModels = []
1197+
const seen = new Set()
1198+
for (const item of Array.isArray(models) ? models : []) {
1199+
const providerKey = typeof item?.providerKey === 'string' ? item.providerKey : ''
1200+
const modelId = typeof item?.modelId === 'string' ? item.modelId : ''
1201+
const key = modelKey(providerKey, modelId)
1202+
if (!this.modelCatalog.has(key) || seen.has(key)) continue
1203+
seen.add(key)
1204+
knownModels.push({ providerKey, modelId, key })
1205+
}
1206+
1207+
const fallbackModels = knownModels.length > 0
1208+
? knownModels
1209+
: [...this.modelCatalog.values()].filter((m) => sources[m.providerKey]?.url && !sources[m.providerKey]?.cliOnly)
1210+
1211+
this.webGlobalBenchmarkRunning = true
1212+
this.webGlobalBenchmarkTotal = fallbackModels.length
1213+
this.webGlobalBenchmarkCompleted = 0
1214+
this.broadcastWebState()
1215+
1216+
const healthPriority = { up: 0, pending: 1, timeout: 2, noauth: 3, auth_error: 4, down: 5 }
1217+
const sorted = [...fallbackModels].sort((a, b) => {
1218+
const aw = this.probeWindows.get(modelKey(a.providerKey, a.modelId)) || []
1219+
const bw = this.probeWindows.get(modelKey(b.providerKey, b.modelId)) || []
1220+
const aLast = aw.at(-1)
1221+
const bLast = bw.at(-1)
1222+
const aState = aLast?.ok ? 'up' : aw.length ? 'down' : 'pending'
1223+
const bState = bLast?.ok ? 'up' : bw.length ? 'down' : 'pending'
1224+
const hpA = healthPriority[aState] ?? 6
1225+
const hpB = healthPriority[bState] ?? 6
1226+
if (hpA !== hpB) return hpA - hpB
1227+
return (aLast?.latencyMs ?? 99999) - (bLast?.latencyMs ?? 99999)
1228+
})
1229+
1230+
const workers = new Array(Math.min(5, sorted.length)).fill(null).map(async () => {
1231+
while (sorted.length > 0) {
1232+
const next = sorted.shift()
1233+
if (!next) break
1234+
try {
1235+
await this.runWebBenchmark(next.providerKey, next.modelId)
1236+
} finally {
1237+
this.webGlobalBenchmarkCompleted += 1
1238+
this.broadcastWebState()
1239+
}
1240+
}
1241+
})
1242+
1243+
void Promise.all(workers).finally(() => {
1244+
this.webGlobalBenchmarkRunning = false
1245+
this.webGlobalBenchmarkTotal = 0
1246+
this.webGlobalBenchmarkCompleted = 0
1247+
this.broadcastWebState()
1248+
})
1249+
1250+
return { started: true, total: fallbackModels.length }
1251+
}
1252+
11341253
statusPayload() {
11351254
const router = this.routerConfig()
11361255
const activeSet = this.getSet(router.activeSet)
@@ -1892,6 +2011,10 @@ class RouterRuntime {
18922011
sendJson(res, 200, getWebModelsPayload(this), { 'x-request-id': requestId })
18932012
return
18942013
}
2014+
if (req.method === 'GET' && url.pathname === '/api/state') {
2015+
sendJson(res, 200, getWebStatePayload(this), { 'x-request-id': requestId })
2016+
return
2017+
}
18952018
if (req.method === 'GET' && url.pathname === '/api/config') {
18962019
sendJson(res, 200, getWebConfigPayload(this), { 'x-request-id': requestId })
18972020
return
@@ -1907,11 +2030,53 @@ class RouterRuntime {
19072030
'Connection': 'keep-alive',
19082031
'x-request-id': requestId,
19092032
})
1910-
res.write(`data: ${JSON.stringify(getWebModelsPayload(this))}\n\n`)
2033+
res.write(`data: ${JSON.stringify(getWebStatePayload(this))}\n\n`)
19112034
this.sseClients.add(res)
19122035
req.on('close', () => this.sseClients.delete(res))
19132036
return
19142037
}
2038+
if (req.method === 'POST' && url.pathname === '/api/activity') {
2039+
sendJson(res, 200, { ok: true }, { 'x-request-id': requestId })
2040+
return
2041+
}
2042+
if (req.method === 'POST' && url.pathname === '/api/benchmark') {
2043+
const body = await readJsonBody(req)
2044+
const providerKey = typeof body.providerKey === 'string' ? body.providerKey : ''
2045+
const modelId = typeof body.modelId === 'string' ? body.modelId : ''
2046+
if (!this.modelCatalog.has(modelKey(providerKey, modelId))) {
2047+
sendJson(res, 404, { error: 'Model not found' }, { 'x-request-id': requestId })
2048+
return
2049+
}
2050+
if (this.webBenchmarkRunning.has(modelKey(providerKey, modelId))) {
2051+
sendJson(res, 409, { error: 'Benchmark already in progress for this model' }, { 'x-request-id': requestId })
2052+
return
2053+
}
2054+
const result = await this.runWebBenchmark(providerKey, modelId)
2055+
sendJson(res, 200, result, { 'x-request-id': requestId })
2056+
return
2057+
}
2058+
if (url.pathname === '/api/global-benchmark') {
2059+
if (req.method === 'GET') {
2060+
sendJson(res, 200, {
2061+
running: this.webGlobalBenchmarkRunning,
2062+
total: this.webGlobalBenchmarkTotal,
2063+
completed: this.webGlobalBenchmarkCompleted,
2064+
}, { 'x-request-id': requestId })
2065+
return
2066+
}
2067+
if (req.method !== 'POST') {
2068+
sendError(res, 405, 'Method not allowed', 'invalid_request_error', 'method_not_allowed', requestId)
2069+
return
2070+
}
2071+
if (this.webGlobalBenchmarkRunning) {
2072+
sendJson(res, 409, { error: 'Global benchmark already running' }, { 'x-request-id': requestId })
2073+
return
2074+
}
2075+
const body = await readJsonBody(req)
2076+
const result = await this.runWebGlobalBenchmark(body.models)
2077+
sendJson(res, result.started ? 202 : 409, result, { 'x-request-id': requestId })
2078+
return
2079+
}
19152080
if (req.method === 'GET' && url.pathname.startsWith('/api/key/')) {
19162081
// 📖 Reveals raw API keys — same-origin only to prevent malicious sites
19172082
// 📖 from exfiltrating provider credentials via XHR/fetch.

src/core/utils.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,8 @@ export function parseArgs(argv) {
471471
flags.push(arg.toLowerCase())
472472
} else if (skipIndices.has(i)) {
473473
// 📖 Skip — this is a value for --tier, not an API key
474+
} else if (i === 0 && arg.toLowerCase() === 'web') {
475+
// 📖 `free-coding-models web` is a subcommand, not a provider API key.
474476
} else if (!apiKey) {
475477
apiKey = arg
476478
}

src/tui/cli-help.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const ANALYSIS_FLAGS = [
3636
]
3737

3838
const CONFIG_FLAGS = [
39+
{ flag: 'web | --web | --gui', description: 'Start the full-catalog realtime Web Dashboard' },
3940
{ flag: '--daemon', description: 'Start the FCM Router daemon + web dashboard (same port)' },
4041
{ flag: '--daemon-bg', description: 'Start the FCM Router daemon in the background' },
4142
{ flag: '--daemon-status', description: 'Print FCM Router daemon status JSON' },
@@ -47,6 +48,7 @@ const CONFIG_FLAGS = [
4748

4849
const EXAMPLES = [
4950
'free-coding-models --help',
51+
'free-coding-models web',
5052
'free-coding-models --daemon',
5153
'free-coding-models --daemon-bg',
5254
'free-coding-models --daemon-status',

test/test.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1786,6 +1786,14 @@ describe('parseArgs', () => {
17861786
assert.equal(parseArgs(argv()).daemonMode, false)
17871787
})
17881788

1789+
it('detects web dashboard mode without treating subcommand as API key', () => {
1790+
const subcommand = parseArgs(argv('web'))
1791+
assert.equal(subcommand.webMode, true)
1792+
assert.equal(subcommand.apiKey, null)
1793+
assert.equal(parseArgs(argv('--web')).webMode, true)
1794+
assert.equal(parseArgs(argv('--gui')).webMode, true)
1795+
})
1796+
17891797
it('detects --help and -h flags', () => {
17901798
assert.equal(parseArgs(argv('--help')).helpMode, true)
17911799
assert.equal(parseArgs(argv('-h')).helpMode, true)
@@ -1842,6 +1850,7 @@ describe('cli help text', () => {
18421850
'--json',
18431851
'--tier <S|A|B|C>',
18441852
'--recommend',
1853+
'web | --web | --gui',
18451854
'--daemon',
18461855
'--daemon-bg',
18471856
'--daemon-status',
@@ -4462,6 +4471,39 @@ describe('getManualInstallCmd', () => {
44624471
})
44634472
})
44644473

4474+
describe('web dashboard table sorting', () => {
4475+
const sortableWebColumns = [
4476+
'mood', 'idx', 'tier', 'sweScore', 'ctx', 'label', 'origin', 'latestPing',
4477+
'avg', 'condition', 'verdict', 'stability', 'uptime', 'aiLatency', 'tps', 'trend',
4478+
]
4479+
4480+
it('keeps every visible web table column explicitly sortable', () => {
4481+
const tableSource = readFileSync(join(ROOT, 'web/src/components/dashboard/ModelTable.jsx'), 'utf8')
4482+
4483+
assert.match(tableSource, /const SORTABLE_COLUMN_IDS = new Set/, 'header clickability must not depend on TanStack display-column accessors')
4484+
assert.match(tableSource, /SORTABLE_COLUMN_IDS\.has\(col\.id\)/, 'headers must use the explicit sortable allowlist')
4485+
4486+
for (const columnId of sortableWebColumns) {
4487+
assert.ok(tableSource.includes(`'${columnId}'`), `${columnId} must be present in the sortable allowlist`)
4488+
const idPattern = columnId === 'condition'
4489+
? /id:\s*'condition',[\s\S]*?enableSorting:\s*true/
4490+
: new RegExp(`(?:id:\\s*'${columnId}'|accessor\\('${columnId}'|accessor\\("${columnId}")[\\s\\S]*?enableSorting:\\s*true`)
4491+
assert.match(tableSource, idPattern, `${columnId} column must opt into sorting`)
4492+
}
4493+
})
4494+
4495+
it('has comparator support for every sortable web table column', () => {
4496+
const filterSource = readFileSync(join(ROOT, 'web/src/hooks/useFilter.js'), 'utf8')
4497+
4498+
for (const columnId of sortableWebColumns) {
4499+
assert.ok(
4500+
filterSource.includes(`sortColumn === '${columnId}'`) || columnId === 'avg',
4501+
`${columnId} must be handled by useFilter sorting`
4502+
)
4503+
}
4504+
})
4505+
})
4506+
44654507
// 📖 Web server tests use real loopback ports so we can verify the startup
44664508
// 📖 fallback behavior without depending on shell scripts or a browser.
44674509
async function getFreePort() {

0 commit comments

Comments
 (0)