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.
Files changed (145) hide show
  1. package/README.md +40 -6
  2. package/bin/free-coding-models.js +10 -0
  3. package/changelog/v0.5.1.md +24 -0
  4. package/changelog/v0.5.4.md +24 -0
  5. package/package.json +11 -3
  6. package/sources.js +2 -0
  7. package/src/core/router-daemon.js +166 -1
  8. package/src/core/utils.js +2 -0
  9. package/src/tui/cli-help.js +2 -0
  10. package/src/tui/key-handler.js +1 -1
  11. package/src/tui/render-table.js +1 -1
  12. package/web/README.md +8 -5
  13. package/web/assets/providers/aistudio/aistudio-text.svg +1 -0
  14. package/web/assets/providers/aistudio/aistudio.svg +1 -0
  15. package/web/assets/providers/cerebras/cerebras-brand-color.svg +1 -0
  16. package/web/assets/providers/cerebras/cerebras-brand.svg +1 -0
  17. package/web/assets/providers/cerebras/cerebras-color.svg +1 -0
  18. package/web/assets/providers/cerebras/cerebras-text.svg +1 -0
  19. package/web/assets/providers/cerebras/cerebras.svg +1 -0
  20. package/web/assets/providers/cloudflare/cloudflare-color.svg +1 -0
  21. package/web/assets/providers/cloudflare/cloudflare-text.svg +1 -0
  22. package/web/assets/providers/cloudflare/cloudflare.svg +1 -0
  23. package/web/assets/providers/gemini/gemini-color.svg +1 -0
  24. package/web/assets/providers/gemini/gemini-text.svg +1 -0
  25. package/web/assets/providers/gemini/gemini.svg +1 -0
  26. package/web/assets/providers/github/github-text.svg +1 -0
  27. package/web/assets/providers/github/github.svg +1 -0
  28. package/web/assets/providers/groq/groq-text.svg +1 -0
  29. package/web/assets/providers/groq/groq.svg +1 -0
  30. package/web/assets/providers/mistral/mistral-color.svg +1 -0
  31. package/web/assets/providers/mistral/mistral-text.svg +1 -0
  32. package/web/assets/providers/mistral/mistral.svg +1 -0
  33. package/web/assets/providers/nvidia/nvidia-color.svg +1 -0
  34. package/web/assets/providers/nvidia/nvidia-text.svg +1 -0
  35. package/web/assets/providers/nvidia/nvidia.svg +1 -0
  36. package/web/assets/providers/opencode/opencode-text.svg +1 -0
  37. package/web/assets/providers/opencode/opencode.svg +1 -0
  38. package/web/assets/providers/openrouter/openrouter (1).svg +1 -0
  39. package/web/assets/providers/openrouter/openrouter-text.svg +1 -0
  40. package/web/assets/providers/openrouter/openrouter.svg +1 -0
  41. package/web/assets/providers/ovhcloud/ovhcloud.svg +12 -0
  42. package/web/assets/providers/qwen/qwen-color.svg +1 -0
  43. package/web/assets/providers/qwen/qwen-text.svg +1 -0
  44. package/web/assets/providers/qwen/qwen.svg +1 -0
  45. package/web/assets/providers/sambanova/sambanova-color.svg +1 -0
  46. package/web/assets/providers/sambanova/sambanova-text.svg +1 -0
  47. package/web/assets/providers/sambanova/sambanova.svg +1 -0
  48. package/web/assets/providers/scalewaylogo/ScalewayLogo.svg +13 -0
  49. package/web/assets/providers/zai/zai-text.svg +1 -0
  50. package/web/assets/providers/zai/zai.svg +1 -0
  51. package/web/dist/assets/index-BoWmUveV.js +39 -0
  52. package/web/dist/assets/index-BrpHevg4.css +1 -0
  53. package/web/dist/favicon.ico +0 -0
  54. package/web/dist/favicons/apple-touch-icon.png +0 -0
  55. package/web/dist/favicons/browserconfig.xml +12 -0
  56. package/web/dist/favicons/favicon-16x16.png +0 -0
  57. package/web/dist/favicons/favicon-192x192.png +0 -0
  58. package/web/dist/favicons/favicon-32x32.png +0 -0
  59. package/web/dist/favicons/favicon-48x48.png +0 -0
  60. package/web/dist/favicons/favicon-512x512.png +0 -0
  61. package/web/dist/favicons/favicon-96x96.png +0 -0
  62. package/web/dist/favicons/favicon.ico +0 -0
  63. package/web/dist/favicons/mstile-150x150.png +0 -0
  64. package/web/dist/favicons/mstile-310x150.png +0 -0
  65. package/web/dist/favicons/mstile-310x310.png +0 -0
  66. package/web/dist/favicons/mstile-512x512.png +0 -0
  67. package/web/dist/favicons/mstile-70x70.png +0 -0
  68. package/web/dist/favicons/site.webmanifest +25 -0
  69. package/web/dist/index.html +42 -6
  70. package/web/index.html +40 -4
  71. package/web/package.json +11 -0
  72. package/web/public/favicon.ico +0 -0
  73. package/web/public/favicons/apple-touch-icon.png +0 -0
  74. package/web/public/favicons/browserconfig.xml +12 -0
  75. package/web/public/favicons/favicon-16x16.png +0 -0
  76. package/web/public/favicons/favicon-192x192.png +0 -0
  77. package/web/public/favicons/favicon-32x32.png +0 -0
  78. package/web/public/favicons/favicon-48x48.png +0 -0
  79. package/web/public/favicons/favicon-512x512.png +0 -0
  80. package/web/public/favicons/favicon-96x96.png +0 -0
  81. package/web/public/favicons/favicon.ico +0 -0
  82. package/web/public/favicons/mstile-150x150.png +0 -0
  83. package/web/public/favicons/mstile-310x150.png +0 -0
  84. package/web/public/favicons/mstile-310x310.png +0 -0
  85. package/web/public/favicons/mstile-512x512.png +0 -0
  86. package/web/public/favicons/mstile-70x70.png +0 -0
  87. package/web/public/favicons/site.webmanifest +25 -0
  88. package/web/server.js +676 -211
  89. package/web/src/App.jsx +233 -71
  90. package/web/src/components/analytics/AnalyticsView.jsx +40 -6
  91. package/web/src/components/analytics/AnalyticsView.module.css +4 -0
  92. package/web/src/components/atoms/AILatencyCell.jsx +38 -0
  93. package/web/src/components/atoms/AILatencyCell.module.css +51 -0
  94. package/web/src/components/atoms/HealthCell.jsx +138 -0
  95. package/web/src/components/atoms/HealthCell.module.css +56 -0
  96. package/web/src/components/atoms/LastPingCell.jsx +35 -0
  97. package/web/src/components/atoms/LastPingCell.module.css +45 -0
  98. package/web/src/components/atoms/MoodCell.jsx +25 -0
  99. package/web/src/components/atoms/MoodCell.module.css +6 -0
  100. package/web/src/components/atoms/NoKeyIcon.jsx +65 -0
  101. package/web/src/components/atoms/NoKeyIcon.module.css +30 -0
  102. package/web/src/components/atoms/ProviderLogo.jsx +188 -0
  103. package/web/src/components/atoms/ProviderLogo.module.css +103 -0
  104. package/web/src/components/atoms/RankCell.jsx +9 -0
  105. package/web/src/components/atoms/RankCell.module.css +9 -0
  106. package/web/src/components/atoms/Sparkline.jsx +21 -2
  107. package/web/src/components/atoms/StabilityCell.module.css +5 -0
  108. package/web/src/components/atoms/StatusDot.module.css +7 -0
  109. package/web/src/components/atoms/TPSCell.jsx +36 -0
  110. package/web/src/components/atoms/TPSCell.module.css +44 -0
  111. package/web/src/components/atoms/TierBadge.module.css +11 -0
  112. package/web/src/components/atoms/Toast.module.css +6 -0
  113. package/web/src/components/atoms/VerdictBadge.jsx +30 -7
  114. package/web/src/components/atoms/VerdictBadge.module.css +36 -15
  115. package/web/src/components/dashboard/DetailPanel.jsx +63 -2
  116. package/web/src/components/dashboard/DetailPanel.module.css +130 -0
  117. package/web/src/components/dashboard/ExportModal.jsx +9 -4
  118. package/web/src/components/dashboard/FilterBar.jsx +228 -33
  119. package/web/src/components/dashboard/FilterBar.module.css +187 -22
  120. package/web/src/components/dashboard/ModelTable.jsx +500 -54
  121. package/web/src/components/dashboard/ModelTable.module.css +318 -31
  122. package/web/src/components/layout/Footer.jsx +1 -1
  123. package/web/src/components/layout/Header.jsx +171 -9
  124. package/web/src/components/layout/Header.module.css +223 -25
  125. package/web/src/components/palette/CommandPalette.jsx +146 -0
  126. package/web/src/components/palette/CommandPalette.module.css +121 -0
  127. package/web/src/components/settings/SettingsView.jsx +24 -6
  128. package/web/src/components/settings/SettingsView.module.css +0 -1
  129. package/web/src/global.css +118 -84
  130. package/web/src/hooks/useColumnSizing.js +211 -0
  131. package/web/src/hooks/useFavorites.js +150 -0
  132. package/web/src/hooks/useFilter.js +262 -37
  133. package/web/src/hooks/useSSE.js +33 -9
  134. package/web/src/hooks/useSocket.js +200 -0
  135. package/web/src/hooks/useTheme.js +72 -16
  136. package/web/src/hooks/useUrlState.js +93 -0
  137. package/web/vite.config.js +41 -0
  138. package/web/dist/assets/index-CGN-0_A0.css +0 -1
  139. package/web/dist/assets/index-CvMUM9Jr.js +0 -11
  140. package/web/src/components/dashboard/StatsBar.jsx +0 -40
  141. package/web/src/components/dashboard/StatsBar.module.css +0 -28
  142. package/web/src/components/layout/Sidebar.jsx +0 -42
  143. package/web/src/components/layout/Sidebar.module.css +0 -76
  144. package/web/src/components/map/MapView.jsx +0 -17
  145. 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
- * 📖 Manages tier/status/provider/text filters + sort column/direction.
5
- * useFilter
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
- setSortColumn((prevCol) => {
21
- if (prevCol === col) {
22
- setSortDirection((prevDir) => (prevDir === 'asc' ? 'desc' : 'asc'))
23
- } else {
24
- setSortDirection('asc')
25
- }
26
- return col
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
- if (searchQuery) {
44
- const q = searchQuery.toLowerCase()
45
- result = result.filter(
46
- (m) =>
47
- m.label.toLowerCase().includes(q) ||
48
- m.modelId.toLowerCase().includes(q) ||
49
- m.origin.toLowerCase().includes(q) ||
50
- m.tier.toLowerCase().includes(q) ||
51
- (m.verdict || '').toLowerCase().includes(q)
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
- const col = sortColumn
58
- if (col === 'idx') cmp = a.idx - b.idx
59
- else if (col === 'tier') cmp = tierRank(a.tier) - tierRank(b.tier)
60
- else if (col === 'label') cmp = a.label.localeCompare(b.label)
61
- else if (col === 'origin') cmp = a.origin.localeCompare(b.origin)
62
- else if (col === 'sweScore') cmp = parseSwe(a.sweScore) - parseSwe(b.sweScore)
63
- else if (col === 'ctx') cmp = formatCtx(a.ctx) - formatCtx(b.ctx)
64
- else if (col === 'latestPing') cmp = (a.latestPing ?? Infinity) - (b.latestPing ?? Infinity)
65
- else if (col === 'avg') cmp = (a.avg === Infinity ? 99999 : a.avg) - (b.avg === Infinity ? 99999 : b.avg)
66
- else if (col === 'stability') cmp = (a.stability ?? -1) - (b.stability ?? -1)
67
- else if (col === 'verdict') cmp = verdictRank(a.verdict) - verdictRank(b.verdict)
68
- else if (col === 'uptime') cmp = (a.uptime ?? 0) - (b.uptime ?? 0)
69
- return sortDirection === 'asc' ? cmp : -cmp
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
  }
@@ -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 reconnectTimer = useRef(null)
16
+ const reconnectRef = useRef(null)
17
+ const mountedRef = useRef(true)
15
18
 
16
19
  const connect = useCallback(() => {
17
- if (esRef.current) esRef.current.close()
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 = () => setConnected(true)
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((c) => c + 1)
42
+ setUpdateCount(c => c + 1)
28
43
  } catch (e) {
29
- console.error('SSE parse error:', e)
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
- clearTimeout(reconnectTimer.current)
36
- reconnectTimer.current = setTimeout(connect, 3000)
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
+ }