free-coding-models 0.5.0 → 0.5.4
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 +40 -6
- package/bin/free-coding-models.js +10 -0
- package/changelog/v0.5.1.md +24 -0
- package/changelog/v0.5.4.md +24 -0
- package/package.json +11 -3
- package/sources.js +2 -0
- 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/key-handler.js +1 -1
- package/src/tui/render-table.js +1 -1
- package/web/README.md +8 -5
- package/web/assets/providers/aistudio/aistudio-text.svg +1 -0
- package/web/assets/providers/aistudio/aistudio.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-brand-color.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-brand.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-color.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-text.svg +1 -0
- package/web/assets/providers/cerebras/cerebras.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare-color.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare-text.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare.svg +1 -0
- package/web/assets/providers/gemini/gemini-color.svg +1 -0
- package/web/assets/providers/gemini/gemini-text.svg +1 -0
- package/web/assets/providers/gemini/gemini.svg +1 -0
- package/web/assets/providers/github/github-text.svg +1 -0
- package/web/assets/providers/github/github.svg +1 -0
- package/web/assets/providers/groq/groq-text.svg +1 -0
- package/web/assets/providers/groq/groq.svg +1 -0
- package/web/assets/providers/mistral/mistral-color.svg +1 -0
- package/web/assets/providers/mistral/mistral-text.svg +1 -0
- package/web/assets/providers/mistral/mistral.svg +1 -0
- package/web/assets/providers/nvidia/nvidia-color.svg +1 -0
- package/web/assets/providers/nvidia/nvidia-text.svg +1 -0
- package/web/assets/providers/nvidia/nvidia.svg +1 -0
- package/web/assets/providers/opencode/opencode-text.svg +1 -0
- package/web/assets/providers/opencode/opencode.svg +1 -0
- package/web/assets/providers/openrouter/openrouter (1).svg +1 -0
- package/web/assets/providers/openrouter/openrouter-text.svg +1 -0
- package/web/assets/providers/openrouter/openrouter.svg +1 -0
- package/web/assets/providers/ovhcloud/ovhcloud.svg +12 -0
- package/web/assets/providers/qwen/qwen-color.svg +1 -0
- package/web/assets/providers/qwen/qwen-text.svg +1 -0
- package/web/assets/providers/qwen/qwen.svg +1 -0
- package/web/assets/providers/sambanova/sambanova-color.svg +1 -0
- package/web/assets/providers/sambanova/sambanova-text.svg +1 -0
- package/web/assets/providers/sambanova/sambanova.svg +1 -0
- package/web/assets/providers/scalewaylogo/ScalewayLogo.svg +13 -0
- package/web/assets/providers/zai/zai-text.svg +1 -0
- package/web/assets/providers/zai/zai.svg +1 -0
- package/web/dist/assets/index-BoWmUveV.js +39 -0
- package/web/dist/assets/index-BrpHevg4.css +1 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/favicons/apple-touch-icon.png +0 -0
- package/web/dist/favicons/browserconfig.xml +12 -0
- package/web/dist/favicons/favicon-16x16.png +0 -0
- package/web/dist/favicons/favicon-192x192.png +0 -0
- package/web/dist/favicons/favicon-32x32.png +0 -0
- package/web/dist/favicons/favicon-48x48.png +0 -0
- package/web/dist/favicons/favicon-512x512.png +0 -0
- package/web/dist/favicons/favicon-96x96.png +0 -0
- package/web/dist/favicons/favicon.ico +0 -0
- package/web/dist/favicons/mstile-150x150.png +0 -0
- package/web/dist/favicons/mstile-310x150.png +0 -0
- package/web/dist/favicons/mstile-310x310.png +0 -0
- package/web/dist/favicons/mstile-512x512.png +0 -0
- package/web/dist/favicons/mstile-70x70.png +0 -0
- package/web/dist/favicons/site.webmanifest +25 -0
- package/web/dist/index.html +42 -6
- package/web/index.html +40 -4
- package/web/package.json +11 -0
- package/web/public/favicon.ico +0 -0
- package/web/public/favicons/apple-touch-icon.png +0 -0
- package/web/public/favicons/browserconfig.xml +12 -0
- package/web/public/favicons/favicon-16x16.png +0 -0
- package/web/public/favicons/favicon-192x192.png +0 -0
- package/web/public/favicons/favicon-32x32.png +0 -0
- package/web/public/favicons/favicon-48x48.png +0 -0
- package/web/public/favicons/favicon-512x512.png +0 -0
- package/web/public/favicons/favicon-96x96.png +0 -0
- package/web/public/favicons/favicon.ico +0 -0
- package/web/public/favicons/mstile-150x150.png +0 -0
- package/web/public/favicons/mstile-310x150.png +0 -0
- package/web/public/favicons/mstile-310x310.png +0 -0
- package/web/public/favicons/mstile-512x512.png +0 -0
- package/web/public/favicons/mstile-70x70.png +0 -0
- package/web/public/favicons/site.webmanifest +25 -0
- package/web/server.js +676 -211
- package/web/src/App.jsx +233 -71
- package/web/src/components/analytics/AnalyticsView.jsx +40 -6
- package/web/src/components/analytics/AnalyticsView.module.css +4 -0
- package/web/src/components/atoms/AILatencyCell.jsx +38 -0
- package/web/src/components/atoms/AILatencyCell.module.css +51 -0
- package/web/src/components/atoms/HealthCell.jsx +138 -0
- package/web/src/components/atoms/HealthCell.module.css +56 -0
- package/web/src/components/atoms/LastPingCell.jsx +35 -0
- package/web/src/components/atoms/LastPingCell.module.css +45 -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/NoKeyIcon.jsx +65 -0
- package/web/src/components/atoms/NoKeyIcon.module.css +30 -0
- package/web/src/components/atoms/ProviderLogo.jsx +188 -0
- package/web/src/components/atoms/ProviderLogo.module.css +103 -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/Sparkline.jsx +21 -2
- package/web/src/components/atoms/StabilityCell.module.css +5 -0
- package/web/src/components/atoms/StatusDot.module.css +7 -0
- package/web/src/components/atoms/TPSCell.jsx +36 -0
- package/web/src/components/atoms/TPSCell.module.css +44 -0
- package/web/src/components/atoms/TierBadge.module.css +11 -0
- package/web/src/components/atoms/Toast.module.css +6 -0
- package/web/src/components/atoms/VerdictBadge.jsx +30 -7
- package/web/src/components/atoms/VerdictBadge.module.css +36 -15
- package/web/src/components/dashboard/DetailPanel.jsx +63 -2
- package/web/src/components/dashboard/DetailPanel.module.css +130 -0
- package/web/src/components/dashboard/ExportModal.jsx +9 -4
- package/web/src/components/dashboard/FilterBar.jsx +228 -33
- package/web/src/components/dashboard/FilterBar.module.css +187 -22
- package/web/src/components/dashboard/ModelTable.jsx +500 -54
- package/web/src/components/dashboard/ModelTable.module.css +318 -31
- package/web/src/components/layout/Footer.jsx +1 -1
- package/web/src/components/layout/Header.jsx +171 -9
- package/web/src/components/layout/Header.module.css +223 -25
- package/web/src/components/palette/CommandPalette.jsx +146 -0
- package/web/src/components/palette/CommandPalette.module.css +121 -0
- package/web/src/components/settings/SettingsView.jsx +24 -6
- package/web/src/components/settings/SettingsView.module.css +0 -1
- package/web/src/global.css +118 -84
- package/web/src/hooks/useColumnSizing.js +211 -0
- package/web/src/hooks/useFavorites.js +150 -0
- package/web/src/hooks/useFilter.js +262 -37
- package/web/src/hooks/useSSE.js +33 -9
- package/web/src/hooks/useSocket.js +200 -0
- package/web/src/hooks/useTheme.js +72 -16
- package/web/src/hooks/useUrlState.js +93 -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/src/components/dashboard/StatsBar.jsx +0 -40
- package/web/src/components/dashboard/StatsBar.module.css +0 -28
- package/web/src/components/layout/Sidebar.jsx +0 -42
- package/web/src/components/layout/Sidebar.module.css +0 -76
- package/web/src/components/map/MapView.jsx +0 -17
- package/web/src/components/map/MapView.module.css +0 -25
|
@@ -1,36 +1,152 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file web/src/hooks/useFilter.js
|
|
3
|
-
* @description React hook for model filtering and sorting state.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* @description React hook for model filtering and tri-state sorting state.
|
|
4
|
+
*
|
|
5
|
+
* 📖 M1 parity additions (vs the previous version):
|
|
6
|
+
* 📖 - verdict filter cycle (`V` in TUI)
|
|
7
|
+
* 📖 - health filter cycle (`H` in TUI)
|
|
8
|
+
* 📖 - visibility mode cycle (`E` in TUI: Normal / Configured-only / Usable-only)
|
|
9
|
+
* 📖 - custom text filter (Ctrl+P → "Apply text filter" in TUI)
|
|
10
|
+
* 📖 - resetView() (`N` in TUI) — clears every filter back to defaults
|
|
11
|
+
*
|
|
12
|
+
* 📖 All TUI filter values (tier / status / provider / verdict / health)
|
|
13
|
+
* 📖 are read in a single sorted pass so the visible list always matches the
|
|
14
|
+
* 📖 header chip bar one-to-one.
|
|
15
|
+
*
|
|
16
|
+
* @functions
|
|
17
|
+
* → useFilter(models) — filter + sort state for the dashboard table
|
|
18
|
+
* @exports useFilter
|
|
6
19
|
*/
|
|
7
20
|
import { useState, useMemo, useCallback } from 'react'
|
|
8
21
|
import { tierRank, verdictRank, parseSwe } from '../utils/ranks.js'
|
|
9
22
|
import { formatCtx } from '../utils/format.js'
|
|
10
23
|
|
|
24
|
+
// 📖 TUI constants mirrored in JS so the Web chips use the same labels/cycle as the TUI.
|
|
25
|
+
export const TIER_CYCLE = ['all', 'S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
|
|
26
|
+
export const STATUS_CYCLE = ['all', 'up', 'down', 'pending']
|
|
27
|
+
// 📖 Verdict filter: full set of verdict states the TUI cycles through.
|
|
28
|
+
// 📖 `all` = no verdict filter (the TUI's null position in VERDICT_CYCLE).
|
|
29
|
+
export const VERDICT_CYCLE = ['all', 'Perfect', 'Normal', 'Spiky', 'Slow', 'Overloaded', 'Down', 'Unstable', 'Pending']
|
|
30
|
+
// 📖 Health filter: matches the TUI's HEALTH_CYCLE exactly.
|
|
31
|
+
export const HEALTH_CYCLE = ['all', 'up', 'timeout', 'down', 'pending', 'noauth', 'auth_error']
|
|
32
|
+
// 📖 Visibility mode: TUI's E key cycle — Normal / Configured-only / Usable-only.
|
|
33
|
+
export const VISIBILITY_CYCLE = ['normal', 'configured', 'usable']
|
|
34
|
+
|
|
35
|
+
function rankOrder(model) {
|
|
36
|
+
return model.idx ?? 9999
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pingHistory(model) {
|
|
40
|
+
return model.pingHistory || model.pings || []
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function latestPingMs(model) {
|
|
44
|
+
const hist = pingHistory(model)
|
|
45
|
+
const latest = hist.length > 0 ? hist[hist.length - 1] : null
|
|
46
|
+
return latest?.ms ?? null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function trendDelta(model) {
|
|
50
|
+
const points = pingHistory(model)
|
|
51
|
+
.map((point) => point?.ms)
|
|
52
|
+
.filter((ms) => typeof ms === 'number' && Number.isFinite(ms))
|
|
53
|
+
if (points.length < 2) return null
|
|
54
|
+
return points[points.length - 1] - points[0]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function numericOrNull(value) {
|
|
58
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function compareNullableNumber(aValue, bValue, direction) {
|
|
62
|
+
const aMissing = aValue == null
|
|
63
|
+
const bMissing = bValue == null
|
|
64
|
+
if (aMissing && bMissing) return 0
|
|
65
|
+
if (aMissing) return 1
|
|
66
|
+
if (bMissing) return -1
|
|
67
|
+
return (aValue - bValue) * direction
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function compareNullableString(aValue, bValue, direction) {
|
|
71
|
+
const aText = typeof aValue === 'string' ? aValue : ''
|
|
72
|
+
const bText = typeof bValue === 'string' ? bValue : ''
|
|
73
|
+
const aMissing = aText.length === 0
|
|
74
|
+
const bMissing = bText.length === 0
|
|
75
|
+
if (aMissing && bMissing) return 0
|
|
76
|
+
if (aMissing) return 1
|
|
77
|
+
if (bMissing) return -1
|
|
78
|
+
return aText.localeCompare(bText) * direction
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 📖 Health order: same precedence as the TUI's HEALTH_CYCLE.
|
|
82
|
+
// 📖 "up" is best, noauth/auth_error are worst.
|
|
83
|
+
const HEALTH_ORDER = {
|
|
84
|
+
up: 0, pending: 1, timeout: 2, down: 3, noauth: 4, auth_error: 5,
|
|
85
|
+
}
|
|
86
|
+
|
|
11
87
|
export function useFilter(models) {
|
|
88
|
+
// 📖 Initial values match the TUI defaults (everything off, "all" everywhere).
|
|
12
89
|
const [filterTier, setFilterTier] = useState('all')
|
|
13
90
|
const [filterStatus, setFilterStatus] = useState('all')
|
|
14
91
|
const [filterProvider, setFilterProvider] = useState('all')
|
|
92
|
+
const [filterVerdict, setFilterVerdict] = useState('all')
|
|
93
|
+
const [filterHealth, setFilterHealth] = useState('all')
|
|
94
|
+
const [visibilityMode, setVisibilityMode] = useState('normal')
|
|
15
95
|
const [searchQuery, setSearchQuery] = useState('')
|
|
96
|
+
const [customTextFilter, setCustomTextFilter] = useState(null)
|
|
16
97
|
const [sortColumn, setSortColumn] = useState('avg')
|
|
17
98
|
const [sortDirection, setSortDirection] = useState('asc')
|
|
18
99
|
|
|
100
|
+
// 📖 Cycle helpers: clicking the same chip advances to the next value in its
|
|
101
|
+
// 📖 cycle, matching the TUI's single-key cycling (T / D / V / H / E).
|
|
102
|
+
const cycleFilter = useCallback((current, cycle, setter) => {
|
|
103
|
+
const idx = cycle.indexOf(current)
|
|
104
|
+
setter(cycle[(idx + 1) % cycle.length])
|
|
105
|
+
}, [])
|
|
106
|
+
|
|
107
|
+
const cycleTier = useCallback(() => cycleFilter(filterTier, TIER_CYCLE, setFilterTier), [filterTier, cycleFilter])
|
|
108
|
+
const cycleStatus = useCallback(() => cycleFilter(filterStatus, STATUS_CYCLE, setFilterStatus), [filterStatus, cycleFilter])
|
|
109
|
+
const cycleVerdict = useCallback(() => cycleFilter(filterVerdict, VERDICT_CYCLE, setFilterVerdict), [filterVerdict, cycleFilter])
|
|
110
|
+
const cycleHealth = useCallback(() => cycleFilter(filterHealth, HEALTH_CYCLE, setFilterHealth), [filterHealth, cycleFilter])
|
|
111
|
+
const cycleVisibility = useCallback(() => cycleFilter(visibilityMode, VISIBILITY_CYCLE, setVisibilityMode), [visibilityMode, cycleFilter])
|
|
112
|
+
|
|
19
113
|
const toggleSort = useCallback((col) => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
114
|
+
if (sortColumn !== col) {
|
|
115
|
+
setSortColumn(col)
|
|
116
|
+
setSortDirection('asc')
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if (sortDirection === 'asc') {
|
|
120
|
+
setSortDirection('desc')
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
// 📖 Third click resets the column: no active sort, catalog/rank order.
|
|
124
|
+
setSortColumn(null)
|
|
125
|
+
setSortDirection('asc')
|
|
126
|
+
}, [sortColumn, sortDirection])
|
|
127
|
+
|
|
128
|
+
// 📖 Reset view — TUI's `N` key. Wipes every filter + sort back to default
|
|
129
|
+
// 📖 and clears the search box. The custom text filter is part of the view
|
|
130
|
+
// 📖 state and gets cleared too.
|
|
131
|
+
const resetView = useCallback(() => {
|
|
132
|
+
setFilterTier('all')
|
|
133
|
+
setFilterStatus('all')
|
|
134
|
+
setFilterProvider('all')
|
|
135
|
+
setFilterVerdict('all')
|
|
136
|
+
setFilterHealth('all')
|
|
137
|
+
setVisibilityMode('normal')
|
|
138
|
+
setSearchQuery('')
|
|
139
|
+
setCustomTextFilter(null)
|
|
140
|
+
setSortColumn('avg')
|
|
141
|
+
setSortDirection('asc')
|
|
28
142
|
}, [])
|
|
29
143
|
|
|
30
144
|
const filtered = useMemo(() => {
|
|
31
145
|
let result = [...models]
|
|
32
146
|
|
|
147
|
+
// ── Tier ──
|
|
33
148
|
if (filterTier !== 'all') result = result.filter((m) => m.tier === filterTier)
|
|
149
|
+
// ── Status (coarser cycle that maps onto the TUI's "health" semantics) ──
|
|
34
150
|
if (filterStatus !== 'all') {
|
|
35
151
|
result = result.filter((m) => {
|
|
36
152
|
if (filterStatus === 'up') return m.status === 'up'
|
|
@@ -39,45 +155,154 @@ export function useFilter(models) {
|
|
|
39
155
|
return true
|
|
40
156
|
})
|
|
41
157
|
}
|
|
158
|
+
// ── Provider ──
|
|
42
159
|
if (filterProvider !== 'all') result = result.filter((m) => m.providerKey === filterProvider)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
)
|
|
160
|
+
// ── Verdict (TUI's `V` cycle) ──
|
|
161
|
+
if (filterVerdict !== 'all') result = result.filter((m) => m.verdict === filterVerdict)
|
|
162
|
+
// ── Health (TUI's `H` cycle — full granularity) ──
|
|
163
|
+
if (filterHealth !== 'all') result = result.filter((m) => m.status === filterHealth)
|
|
164
|
+
// ── Visibility mode (TUI's `E` cycle) ──
|
|
165
|
+
if (visibilityMode === 'configured') {
|
|
166
|
+
// 📖 Hide models with no API key OR auth/noauth errors. Keep timeout/429.
|
|
167
|
+
result = result.filter((m) => m.hasApiKey && m.status !== 'noauth' && m.status !== 'auth_error')
|
|
168
|
+
} else if (visibilityMode === 'usable') {
|
|
169
|
+
// 📖 Only Health UP with verdict in the good set (Perfect / Normal / Slow).
|
|
170
|
+
result = result.filter((m) => {
|
|
171
|
+
if (m.status !== 'up') return false
|
|
172
|
+
return ['Perfect', 'Normal', 'Slow'].includes(m.verdict)
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
// ── Search + custom text filter (TUI's Ctrl+P "Apply text filter") ──
|
|
176
|
+
const searchLower = searchQuery.trim().toLowerCase()
|
|
177
|
+
const customLower = (customTextFilter || '').trim().toLowerCase()
|
|
178
|
+
if (searchLower) {
|
|
179
|
+
result = result.filter((m) => (
|
|
180
|
+
m.label.toLowerCase().includes(searchLower) ||
|
|
181
|
+
m.modelId.toLowerCase().includes(searchLower) ||
|
|
182
|
+
m.origin.toLowerCase().includes(searchLower) ||
|
|
183
|
+
m.tier.toLowerCase().includes(searchLower) ||
|
|
184
|
+
(m.verdict || '').toLowerCase().includes(searchLower)
|
|
185
|
+
))
|
|
186
|
+
}
|
|
187
|
+
if (customLower) {
|
|
188
|
+
result = result.filter((m) => (
|
|
189
|
+
m.label.toLowerCase().includes(customLower) ||
|
|
190
|
+
m.modelId.toLowerCase().includes(customLower) ||
|
|
191
|
+
m.origin.toLowerCase().includes(customLower) ||
|
|
192
|
+
(m.ctx || '').toLowerCase().includes(customLower) ||
|
|
193
|
+
(m.providerKey || '').toLowerCase().includes(customLower)
|
|
194
|
+
))
|
|
53
195
|
}
|
|
54
196
|
|
|
55
197
|
result.sort((a, b) => {
|
|
198
|
+
if (!sortColumn) return rankOrder(a) - rankOrder(b)
|
|
199
|
+
|
|
200
|
+
const direction = sortDirection === 'desc' ? -1 : 1
|
|
56
201
|
let cmp = 0
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
else if (
|
|
61
|
-
|
|
62
|
-
else if (
|
|
63
|
-
|
|
64
|
-
else if (
|
|
65
|
-
|
|
66
|
-
else if (
|
|
67
|
-
|
|
68
|
-
else if (
|
|
69
|
-
|
|
202
|
+
|
|
203
|
+
if (sortColumn === 'mood') {
|
|
204
|
+
cmp = compareNullableNumber(verdictRank(a.verdict), verdictRank(b.verdict), direction)
|
|
205
|
+
} else if (sortColumn === 'idx') {
|
|
206
|
+
cmp = compareNullableNumber(rankOrder(a), rankOrder(b), direction)
|
|
207
|
+
} else if (sortColumn === 'tier') {
|
|
208
|
+
cmp = compareNullableNumber(tierRank(a.tier), tierRank(b.tier), direction)
|
|
209
|
+
} else if (sortColumn === 'label') {
|
|
210
|
+
cmp = compareNullableString(a.label, b.label, direction)
|
|
211
|
+
} else if (sortColumn === 'origin') {
|
|
212
|
+
cmp = compareNullableString(a.origin, b.origin, direction)
|
|
213
|
+
} else if (sortColumn === 'sweScore') {
|
|
214
|
+
cmp = compareNullableNumber(parseSwe(a.sweScore), parseSwe(b.sweScore), direction)
|
|
215
|
+
} else if (sortColumn === 'ctx') {
|
|
216
|
+
cmp = compareNullableNumber(formatCtx(a.ctx), formatCtx(b.ctx), direction)
|
|
217
|
+
} else if (sortColumn === 'latestPing') {
|
|
218
|
+
cmp = compareNullableNumber(latestPingMs(a), latestPingMs(b), direction)
|
|
219
|
+
} else if (sortColumn === 'avg') {
|
|
220
|
+
const aAvg = a.avg == null || a.avg === Infinity || a.avg > 99000 ? null : a.avg
|
|
221
|
+
const bAvg = b.avg == null || b.avg === Infinity || b.avg > 99000 ? null : b.avg
|
|
222
|
+
cmp = compareNullableNumber(aAvg, bAvg, direction)
|
|
223
|
+
} else if (sortColumn === 'condition') {
|
|
224
|
+
cmp = compareNullableNumber(HEALTH_ORDER[a.status] ?? 9, HEALTH_ORDER[b.status] ?? 9, direction)
|
|
225
|
+
} else if (sortColumn === 'verdict') {
|
|
226
|
+
cmp = compareNullableNumber(verdictRank(a.verdict), verdictRank(b.verdict), direction)
|
|
227
|
+
} else if (sortColumn === 'stability') {
|
|
228
|
+
cmp = compareNullableNumber(numericOrNull(a.stability), numericOrNull(b.stability), direction)
|
|
229
|
+
} else if (sortColumn === 'uptime') {
|
|
230
|
+
cmp = compareNullableNumber(numericOrNull(a.uptime), numericOrNull(b.uptime), direction)
|
|
231
|
+
} else if (sortColumn === 'aiLatency') {
|
|
232
|
+
cmp = compareNullableNumber(a.benchmark?.ok ? a.benchmark.totalMs : null, b.benchmark?.ok ? b.benchmark.totalMs : null, direction)
|
|
233
|
+
} else if (sortColumn === 'tps') {
|
|
234
|
+
cmp = compareNullableNumber(a.benchmark?.ok ? a.benchmark.tokensPerSecond ?? 0 : null, b.benchmark?.ok ? b.benchmark.tokensPerSecond ?? 0 : null, direction)
|
|
235
|
+
} else if (sortColumn === 'trend') {
|
|
236
|
+
cmp = compareNullableNumber(trendDelta(a), trendDelta(b), direction)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return cmp || (rankOrder(a) - rankOrder(b))
|
|
70
240
|
})
|
|
71
241
|
|
|
72
242
|
return result
|
|
73
|
-
}, [models, filterTier, filterStatus, filterProvider, searchQuery, sortColumn, sortDirection])
|
|
243
|
+
}, [models, filterTier, filterStatus, filterProvider, filterVerdict, filterHealth, visibilityMode, searchQuery, customTextFilter, sortColumn, sortDirection])
|
|
74
244
|
|
|
75
245
|
return {
|
|
76
246
|
filtered,
|
|
77
|
-
filterTier, setFilterTier,
|
|
78
|
-
filterStatus, setFilterStatus,
|
|
247
|
+
filterTier, setFilterTier, cycleTier,
|
|
248
|
+
filterStatus, setFilterStatus, cycleStatus,
|
|
79
249
|
filterProvider, setFilterProvider,
|
|
250
|
+
filterVerdict, setFilterVerdict, cycleVerdict,
|
|
251
|
+
filterHealth, setFilterHealth, cycleHealth,
|
|
252
|
+
visibilityMode, setVisibilityMode, cycleVisibility,
|
|
80
253
|
searchQuery, setSearchQuery,
|
|
254
|
+
customTextFilter, setCustomTextFilter,
|
|
81
255
|
sortColumn, sortDirection, toggleSort,
|
|
256
|
+
resetView,
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── 3-bucket sort helpers (extracted for unit testing) ────────────────
|
|
261
|
+
// 📖 The AI Latency + TPS columns sort rows by a "bucket" then by value:
|
|
262
|
+
// 📖 bucket 0 = completed benchmark (has a real result)
|
|
263
|
+
// 📖 bucket 1 = benchmark currently running (no result yet, but isBenchmarking)
|
|
264
|
+
// 📖 bucket 2 = never tested (no benchmark object, not running)
|
|
265
|
+
// 📖 Rows in buckets 1 and 2 are NOT sorted by their numeric value (they don't
|
|
266
|
+
// 📖 have one) — the comparator returns 0 inside each bucket so the rank-based
|
|
267
|
+
// 📖 tie-breaker takes over.
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 📖 Classify a model into one of the 3 benchmark buckets.
|
|
271
|
+
* @param {{benchmark?: {ok?: boolean}|null, isBenchmarking?: boolean}} model
|
|
272
|
+
* @returns {0|1|2}
|
|
273
|
+
*/
|
|
274
|
+
export function benchmarkBucket(model) {
|
|
275
|
+
if (!model) return 2
|
|
276
|
+
if (model.benchmark && model.benchmark.ok === true) return 0
|
|
277
|
+
if (model.isBenchmarking === true) return 1
|
|
278
|
+
return 2
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 📖 Comparator for the AI Latency / TPS sort columns.
|
|
283
|
+
* 📖 Always agrees on bucket order (completed → running → never) regardless of
|
|
284
|
+
* 📖 direction. Within each bucket, returns 0 so the rank tie-breaker applies.
|
|
285
|
+
* 📖 `valueGetter` reads the numeric metric (totalMs for latency, tokensPerSecond for TPS).
|
|
286
|
+
* @param {object} a
|
|
287
|
+
* @param {object} b
|
|
288
|
+
* @param {1|-1} direction — 1 = ascending, -1 = descending
|
|
289
|
+
* @param {(bench: object) => number} valueGetter
|
|
290
|
+
* @returns {number}
|
|
291
|
+
*/
|
|
292
|
+
export function compareBenchmark(a, b, direction, valueGetter) {
|
|
293
|
+
const ba = benchmarkBucket(a)
|
|
294
|
+
const bb = benchmarkBucket(b)
|
|
295
|
+
if (ba !== bb) {
|
|
296
|
+
// 📖 Completed always first, never-tested always last, regardless of direction.
|
|
297
|
+
if (ba < bb) return -1
|
|
298
|
+
return 1
|
|
82
299
|
}
|
|
300
|
+
if (ba !== 0) return 0
|
|
301
|
+
// 📖 Both completed: sort by the actual metric.
|
|
302
|
+
const va = valueGetter ? valueGetter(a.benchmark) : null
|
|
303
|
+
const vb = valueGetter ? valueGetter(b.benchmark) : null
|
|
304
|
+
if (va == null && vb == null) return 0
|
|
305
|
+
if (va == null) return 1
|
|
306
|
+
if (vb == null) return -1
|
|
307
|
+
return (va - vb) * direction
|
|
83
308
|
}
|
package/web/src/hooks/useSSE.js
CHANGED
|
@@ -6,44 +6,68 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
8
8
|
|
|
9
|
+
const RECONNECT_DELAY = 2000
|
|
10
|
+
|
|
9
11
|
export function useSSE(url = '/api/events') {
|
|
10
12
|
const [models, setModels] = useState([])
|
|
11
13
|
const [connected, setConnected] = useState(false)
|
|
12
14
|
const [updateCount, setUpdateCount] = useState(0)
|
|
13
15
|
const esRef = useRef(null)
|
|
14
|
-
const
|
|
16
|
+
const reconnectRef = useRef(null)
|
|
17
|
+
const mountedRef = useRef(true)
|
|
15
18
|
|
|
16
19
|
const connect = useCallback(() => {
|
|
17
|
-
if (
|
|
20
|
+
if (!mountedRef.current) return
|
|
21
|
+
|
|
22
|
+
// Close existing connection
|
|
23
|
+
if (esRef.current) {
|
|
24
|
+
esRef.current.close()
|
|
25
|
+
esRef.current = null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
clearTimeout(reconnectRef.current)
|
|
18
29
|
|
|
19
30
|
const es = new EventSource(url)
|
|
20
31
|
esRef.current = es
|
|
21
32
|
|
|
22
|
-
es.onopen = () =>
|
|
33
|
+
es.onopen = () => {
|
|
34
|
+
if (mountedRef.current) setConnected(true)
|
|
35
|
+
}
|
|
36
|
+
|
|
23
37
|
es.onmessage = (event) => {
|
|
38
|
+
if (!mountedRef.current) return
|
|
24
39
|
try {
|
|
25
40
|
const data = JSON.parse(event.data)
|
|
26
41
|
setModels(data)
|
|
27
|
-
setUpdateCount(
|
|
42
|
+
setUpdateCount(c => c + 1)
|
|
28
43
|
} catch (e) {
|
|
29
|
-
console.
|
|
44
|
+
console.warn('[useSSE] parse error:', e)
|
|
30
45
|
}
|
|
31
46
|
}
|
|
47
|
+
|
|
32
48
|
es.onerror = () => {
|
|
33
49
|
setConnected(false)
|
|
34
50
|
es.close()
|
|
35
|
-
|
|
36
|
-
|
|
51
|
+
esRef.current = null
|
|
52
|
+
|
|
53
|
+
if (mountedRef.current) {
|
|
54
|
+
reconnectRef.current = setTimeout(() => {
|
|
55
|
+
if (mountedRef.current) connect()
|
|
56
|
+
}, RECONNECT_DELAY)
|
|
57
|
+
}
|
|
37
58
|
}
|
|
38
59
|
}, [url])
|
|
39
60
|
|
|
40
61
|
useEffect(() => {
|
|
62
|
+
mountedRef.current = true
|
|
41
63
|
connect()
|
|
64
|
+
|
|
42
65
|
return () => {
|
|
66
|
+
mountedRef.current = false
|
|
67
|
+
clearTimeout(reconnectRef.current)
|
|
43
68
|
esRef.current?.close()
|
|
44
|
-
clearTimeout(reconnectTimer.current)
|
|
45
69
|
}
|
|
46
70
|
}, [connect])
|
|
47
71
|
|
|
48
72
|
return { models, connected, updateCount }
|
|
49
|
-
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/hooks/useSocket.js
|
|
3
|
+
* @description Realtime model-data hook with Socket.IO primary, SSE fallback, and REST polling safety net.
|
|
4
|
+
*
|
|
5
|
+
* 📖 The dashboard runs locally, so freshness matters more than shaving every
|
|
6
|
+
* byte. Socket.IO gives the snappiest path in `pnpm dev:web`; SSE keeps Docker /
|
|
7
|
+
* daemon-style servers working without Socket.IO; REST polling guarantees the UI
|
|
8
|
+
* eventually recovers if both streaming transports are interrupted.
|
|
9
|
+
*
|
|
10
|
+
* @functions
|
|
11
|
+
* → useSocket(serverUrl) — Subscribe to live dashboard state
|
|
12
|
+
* @exports useSocket
|
|
13
|
+
*/
|
|
14
|
+
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
15
|
+
import { io } from 'socket.io-client'
|
|
16
|
+
|
|
17
|
+
const REST_FALLBACK_INTERVAL_MS = 2_000
|
|
18
|
+
const STALE_UPDATE_MS = 4_000
|
|
19
|
+
const ACTIVITY_THROTTLE_MS = 1_000
|
|
20
|
+
|
|
21
|
+
function normalizePayload(data) {
|
|
22
|
+
if (!data) return null
|
|
23
|
+
if (Array.isArray(data)) return { models: data }
|
|
24
|
+
if (Array.isArray(data.models)) return data
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sameOriginUrl(path, serverUrl) {
|
|
29
|
+
if (!serverUrl) return path
|
|
30
|
+
return `${serverUrl.replace(/\/$/, '')}${path}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useSocket(serverUrl = '') {
|
|
34
|
+
const [models, setModels] = useState([])
|
|
35
|
+
const [connected, setConnected] = useState(false)
|
|
36
|
+
const [transport, setTransport] = useState('connecting')
|
|
37
|
+
const [updateCount, setUpdateCount] = useState(0)
|
|
38
|
+
const [nextPingAt, setNextPingAt] = useState(null)
|
|
39
|
+
const [serverIsPinging, setServerIsPinging] = useState(false)
|
|
40
|
+
const [pendingPings, setPendingPings] = useState(0)
|
|
41
|
+
const [pingMode, setPingMode] = useState('speed')
|
|
42
|
+
const [globalBenchmarkRunning, setGlobalBenchmarkRunning] = useState(false)
|
|
43
|
+
const [globalBenchmarkTotal, setGlobalBenchmarkTotal] = useState(0)
|
|
44
|
+
const [globalBenchmarkCompleted, setGlobalBenchmarkCompleted] = useState(0)
|
|
45
|
+
|
|
46
|
+
const socketRef = useRef(null)
|
|
47
|
+
const esRef = useRef(null)
|
|
48
|
+
const pollRef = useRef(null)
|
|
49
|
+
const lastUpdateRef = useRef(0)
|
|
50
|
+
const lastActivityRef = useRef(0)
|
|
51
|
+
const mountedRef = useRef(false)
|
|
52
|
+
|
|
53
|
+
const applyPayload = useCallback((raw, source = 'unknown') => {
|
|
54
|
+
const data = normalizePayload(raw)
|
|
55
|
+
if (!data || !mountedRef.current) return
|
|
56
|
+
|
|
57
|
+
setModels(data.models ?? [])
|
|
58
|
+
setPingMode(data.pingMode ?? 'speed')
|
|
59
|
+
setNextPingAt(data.nextPingAt ?? null)
|
|
60
|
+
setServerIsPinging(Boolean(data.isPinging))
|
|
61
|
+
setPendingPings(Number.isFinite(data.pendingPings) ? data.pendingPings : 0)
|
|
62
|
+
setGlobalBenchmarkRunning(Boolean(data.globalBenchmarkRunning))
|
|
63
|
+
setGlobalBenchmarkTotal(Number.isFinite(data.globalBenchmarkTotal) ? data.globalBenchmarkTotal : 0)
|
|
64
|
+
setGlobalBenchmarkCompleted(Number.isFinite(data.globalBenchmarkCompleted) ? data.globalBenchmarkCompleted : 0)
|
|
65
|
+
setUpdateCount((count) => count + 1)
|
|
66
|
+
lastUpdateRef.current = Date.now()
|
|
67
|
+
if (source !== 'poll') {
|
|
68
|
+
setConnected(true)
|
|
69
|
+
setTransport(source)
|
|
70
|
+
}
|
|
71
|
+
}, [])
|
|
72
|
+
|
|
73
|
+
const fetchSnapshot = useCallback(async () => {
|
|
74
|
+
try {
|
|
75
|
+
const stateResponse = await fetch(sameOriginUrl('/api/state', serverUrl), { headers: { Accept: 'application/json' } })
|
|
76
|
+
if (stateResponse.ok) {
|
|
77
|
+
applyPayload(await stateResponse.json(), 'poll')
|
|
78
|
+
setConnected(true)
|
|
79
|
+
setTransport((current) => current === 'socket' || current === 'sse' ? current : 'poll')
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
} catch {}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetch(sameOriginUrl('/api/models', serverUrl), { headers: { Accept: 'application/json' } })
|
|
86
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
87
|
+
applyPayload(await response.json(), 'poll')
|
|
88
|
+
setConnected(true)
|
|
89
|
+
setTransport((current) => current === 'socket' || current === 'sse' ? current : 'poll')
|
|
90
|
+
} catch {
|
|
91
|
+
setConnected(false)
|
|
92
|
+
setTransport('offline')
|
|
93
|
+
}
|
|
94
|
+
}, [applyPayload, serverUrl])
|
|
95
|
+
|
|
96
|
+
const startSse = useCallback(() => {
|
|
97
|
+
if (esRef.current || !mountedRef.current) return
|
|
98
|
+
try {
|
|
99
|
+
const es = new EventSource(sameOriginUrl('/api/events', serverUrl))
|
|
100
|
+
esRef.current = es
|
|
101
|
+
es.onopen = () => {
|
|
102
|
+
if (!mountedRef.current) return
|
|
103
|
+
setConnected(true)
|
|
104
|
+
setTransport('sse')
|
|
105
|
+
}
|
|
106
|
+
const handleSsePayload = (event) => {
|
|
107
|
+
try { applyPayload(JSON.parse(event.data), 'sse') }
|
|
108
|
+
catch (err) { console.warn('[useSocket] SSE parse error:', err) }
|
|
109
|
+
}
|
|
110
|
+
es.onmessage = handleSsePayload
|
|
111
|
+
es.addEventListener('models', handleSsePayload)
|
|
112
|
+
es.onerror = () => {
|
|
113
|
+
es.close()
|
|
114
|
+
if (esRef.current === es) esRef.current = null
|
|
115
|
+
if (!socketRef.current?.connected) setConnected(false)
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
esRef.current = null
|
|
119
|
+
}
|
|
120
|
+
}, [applyPayload, serverUrl])
|
|
121
|
+
|
|
122
|
+
const sendActivity = useCallback(() => {
|
|
123
|
+
const now = Date.now()
|
|
124
|
+
if (now - lastActivityRef.current < ACTIVITY_THROTTLE_MS) return
|
|
125
|
+
lastActivityRef.current = now
|
|
126
|
+
|
|
127
|
+
if (socketRef.current?.connected) {
|
|
128
|
+
socketRef.current.emit('client:activity')
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fetch(sameOriginUrl('/api/activity', serverUrl), { method: 'POST' }).catch(() => {})
|
|
133
|
+
}, [serverUrl])
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
mountedRef.current = true
|
|
137
|
+
|
|
138
|
+
const socket = io(serverUrl || undefined, {
|
|
139
|
+
transports: ['websocket', 'polling'],
|
|
140
|
+
reconnectionDelay: 500,
|
|
141
|
+
reconnectionDelayMax: 2500,
|
|
142
|
+
timeout: 1200,
|
|
143
|
+
})
|
|
144
|
+
socketRef.current = socket
|
|
145
|
+
|
|
146
|
+
socket.on('connect', () => {
|
|
147
|
+
setConnected(true)
|
|
148
|
+
setTransport('socket')
|
|
149
|
+
esRef.current?.close()
|
|
150
|
+
esRef.current = null
|
|
151
|
+
socket.emit('models:refresh')
|
|
152
|
+
socket.emit('client:activity')
|
|
153
|
+
})
|
|
154
|
+
socket.on('disconnect', () => {
|
|
155
|
+
if (!mountedRef.current) return
|
|
156
|
+
setConnected(false)
|
|
157
|
+
startSse()
|
|
158
|
+
})
|
|
159
|
+
socket.on('connect_error', () => {
|
|
160
|
+
if (!mountedRef.current) return
|
|
161
|
+
setConnected(false)
|
|
162
|
+
startSse()
|
|
163
|
+
})
|
|
164
|
+
socket.on('models:update', (data) => applyPayload(data, 'socket'))
|
|
165
|
+
|
|
166
|
+
pollRef.current = setInterval(() => {
|
|
167
|
+
const stale = Date.now() - lastUpdateRef.current > STALE_UPDATE_MS
|
|
168
|
+
if (!socket.connected || stale) void fetchSnapshot()
|
|
169
|
+
}, REST_FALLBACK_INTERVAL_MS)
|
|
170
|
+
|
|
171
|
+
void fetchSnapshot()
|
|
172
|
+
|
|
173
|
+
const activityEvents = ['keydown', 'pointerdown', 'mousemove', 'focus']
|
|
174
|
+
for (const eventName of activityEvents) window.addEventListener(eventName, sendActivity, { passive: true })
|
|
175
|
+
|
|
176
|
+
return () => {
|
|
177
|
+
mountedRef.current = false
|
|
178
|
+
for (const eventName of activityEvents) window.removeEventListener(eventName, sendActivity)
|
|
179
|
+
if (pollRef.current) clearInterval(pollRef.current)
|
|
180
|
+
socket.disconnect()
|
|
181
|
+
esRef.current?.close()
|
|
182
|
+
socketRef.current = null
|
|
183
|
+
esRef.current = null
|
|
184
|
+
}
|
|
185
|
+
}, [applyPayload, fetchSnapshot, sendActivity, serverUrl, startSse])
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
models,
|
|
189
|
+
connected,
|
|
190
|
+
transport,
|
|
191
|
+
updateCount,
|
|
192
|
+
nextPingAt,
|
|
193
|
+
isPinging: serverIsPinging,
|
|
194
|
+
pendingPings,
|
|
195
|
+
pingMode,
|
|
196
|
+
globalBenchmarkRunning,
|
|
197
|
+
globalBenchmarkTotal,
|
|
198
|
+
globalBenchmarkCompleted,
|
|
199
|
+
}
|
|
200
|
+
}
|