free-coding-models 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +9 -1
  2. package/bin/free-coding-models.js +10 -0
  3. package/changelog/v0.5.1.md +24 -0
  4. package/package.json +7 -2
  5. package/src/core/router-daemon.js +166 -1
  6. package/src/core/utils.js +2 -0
  7. package/src/tui/cli-help.js +2 -0
  8. package/src/tui/render-table.js +1 -1
  9. package/web/README.md +8 -5
  10. package/web/dist/assets/index-ByGf4Kq-.js +14 -0
  11. package/web/dist/assets/index-Ds7wmHBv.css +1 -0
  12. package/web/dist/index.html +3 -6
  13. package/web/index.html +1 -4
  14. package/web/package.json +11 -0
  15. package/web/server.js +606 -211
  16. package/web/src/App.jsx +54 -12
  17. package/web/src/components/analytics/AnalyticsView.jsx +10 -4
  18. package/web/src/components/atoms/AILatencyCell.jsx +38 -0
  19. package/web/src/components/atoms/AILatencyCell.module.css +43 -0
  20. package/web/src/components/atoms/HealthCell.jsx +53 -0
  21. package/web/src/components/atoms/HealthCell.module.css +15 -0
  22. package/web/src/components/atoms/LastPingCell.jsx +35 -0
  23. package/web/src/components/atoms/LastPingCell.module.css +35 -0
  24. package/web/src/components/atoms/MoodCell.jsx +25 -0
  25. package/web/src/components/atoms/MoodCell.module.css +6 -0
  26. package/web/src/components/atoms/RankCell.jsx +9 -0
  27. package/web/src/components/atoms/RankCell.module.css +9 -0
  28. package/web/src/components/atoms/TPSCell.jsx +36 -0
  29. package/web/src/components/atoms/TPSCell.module.css +38 -0
  30. package/web/src/components/atoms/VerdictBadge.jsx +30 -7
  31. package/web/src/components/atoms/VerdictBadge.module.css +24 -15
  32. package/web/src/components/dashboard/ExportModal.jsx +9 -4
  33. package/web/src/components/dashboard/FilterBar.jsx +112 -10
  34. package/web/src/components/dashboard/FilterBar.module.css +86 -1
  35. package/web/src/components/dashboard/ModelTable.jsx +293 -52
  36. package/web/src/components/dashboard/ModelTable.module.css +131 -33
  37. package/web/src/components/dashboard/StatsBar.jsx +7 -5
  38. package/web/src/components/layout/Footer.jsx +1 -1
  39. package/web/src/components/layout/Header.jsx +43 -9
  40. package/web/src/components/layout/Header.module.css +38 -4
  41. package/web/src/components/layout/Sidebar.jsx +19 -11
  42. package/web/src/components/layout/Sidebar.module.css +15 -5
  43. package/web/src/components/settings/SettingsView.jsx +24 -6
  44. package/web/src/components/settings/SettingsView.module.css +0 -1
  45. package/web/src/global.css +70 -73
  46. package/web/src/hooks/useFilter.js +117 -25
  47. package/web/src/hooks/useSSE.js +33 -9
  48. package/web/src/hooks/useSocket.js +200 -0
  49. package/web/vite.config.js +41 -0
  50. package/web/dist/assets/index-CGN-0_A0.css +0 -1
  51. package/web/dist/assets/index-CvMUM9Jr.js +0 -11
@@ -1,29 +1,264 @@
1
1
  /**
2
2
  * @file web/src/components/dashboard/ModelTable.jsx
3
- * @description Main data table with medal rankings for top 3 fastest models.
3
+ * @description Main data table with ALL CLI columns powered by TanStack Table.
4
+ * 📖 Full CLI column parity: ❔(mood) | # | Tier | SWE% | Ctx | Model | Provider | Last Ping | Avg | Health | Verdict | Stability | Up% | AI Lat. | TPS | Trend
5
+ * 📖 Clickable headers for sorting, medal rankings for top 3, horizontal scroll.
6
+ * 📖 Sorting is handled by useFilter hook which pushes null/Infinity values to bottom.
4
7
  */
5
8
  import { useMemo } from 'react'
9
+ import {
10
+ useReactTable,
11
+ getCoreRowModel,
12
+ flexRender,
13
+ createColumnHelper,
14
+ } from '@tanstack/react-table'
15
+
16
+ import MoodCell from '../atoms/MoodCell.jsx'
6
17
  import TierBadge from '../atoms/TierBadge.jsx'
7
18
  import StatusDot from '../atoms/StatusDot.jsx'
8
- import VerdictBadge from '../atoms/VerdictBadge.jsx'
19
+ import LastPingCell from '../atoms/LastPingCell.jsx'
20
+ import HealthCell from '../atoms/HealthCell.jsx'
9
21
  import StabilityCell from '../atoms/StabilityCell.jsx'
22
+ import VerdictBadge from '../atoms/VerdictBadge.jsx'
10
23
  import Sparkline from '../atoms/Sparkline.jsx'
24
+ import AILatencyCell from '../atoms/AILatencyCell.jsx'
25
+ import TPSCell from '../atoms/TPSCell.jsx'
26
+
11
27
  import { pingClass } from '../../utils/format.js'
12
28
  import { sweClass } from '../../utils/ranks.js'
13
29
  import styles from './ModelTable.module.css'
14
30
 
15
- export default function ModelTable({ filtered, onSelectModel }) {
31
+ const colHelper = createColumnHelper()
32
+
33
+ const SORTABLE_COLUMN_IDS = new Set([
34
+ 'mood', 'idx', 'tier', 'sweScore', 'ctx', 'label', 'origin', 'latestPing',
35
+ 'avg', 'condition', 'verdict', 'stability', 'uptime', 'aiLatency', 'tps', 'trend',
36
+ ])
37
+
38
+ // ─── Cell renderers ───────────────────────────────────────────────────────────
39
+ function MoodCellRenderer({ row }) {
40
+ return <MoodCell verdict={row.original.verdict} />
41
+ }
42
+
43
+ function RankCellRenderer({ row }) {
44
+ return <span className={styles.rankNum}>{row.original.idx ?? row.index + 1}</span>
45
+ }
46
+
47
+ function ModelCellRenderer({ row }) {
48
+ const m = row.original
49
+ return (
50
+ <div className={styles.modelCell}>
51
+ <StatusDot status={m.status} />
52
+ <div className={styles.modelMeta}>
53
+ <div className={styles.modelHeader}>
54
+ <span className={styles.modelName}>{m.label}</span>
55
+ {!m.hasApiKey && !m.cliOnly && <span className={styles.noKey}>NO KEY</span>}
56
+ </div>
57
+ <div className={styles.modelId}>{m.modelId}</div>
58
+ </div>
59
+ </div>
60
+ )
61
+ }
62
+
63
+ function SWECellRenderer({ row }) {
64
+ const m = row.original
65
+ const cls = sweClass(m.sweScore)
66
+ return <span className={`${styles.swe} ${styles[cls]}`}>{m.sweScore || '—'}</span>
67
+ }
68
+
69
+ function CtxCellRenderer({ row }) {
70
+ return <span className={styles.ctx}>{row.original.ctx || '—'}</span>
71
+ }
72
+
73
+ function ProviderCellRenderer({ row }) {
74
+ return <span className={styles.providerPill}>{row.original.origin}</span>
75
+ }
76
+
77
+ function LastPingCellRenderer({ row }) {
78
+ const m = row.original
79
+ const hist = m.pingHistory || m.pings || []
80
+ const latest = hist.length > 0 ? hist[hist.length - 1] : null
81
+ return <LastPingCell ms={latest?.ms ?? null} isPinging={Boolean(m.isPinging)} />
82
+ }
83
+
84
+ function AvgPingCellRenderer({ row }) {
85
+ const m = row.original
86
+ const cls = pingClass(m.avg)
87
+ return (
88
+ <span className={`${styles.ping} ${styles[cls]}`}>
89
+ {m.avg == null || m.avg === Infinity || m.avg > 99000 ? '—' : m.avg}
90
+ </span>
91
+ )
92
+ }
93
+
94
+ function HealthCellRenderer({ row }) {
95
+ const m = row.original
96
+ return <HealthCell status={m.status} httpCode={m.httpCode} />
97
+ }
98
+
99
+ function VerdictCellRenderer({ row }) {
100
+ const m = row.original
101
+ return <VerdictBadge verdict={m.verdict} httpCode={m.httpCode} />
102
+ }
103
+
104
+ function StabilityCellRenderer({ row }) {
105
+ return <StabilityCell score={row.original.stability} />
106
+ }
107
+
108
+ function UptimeCellRenderer({ row }) {
109
+ const m = row.original
110
+ return <span className={styles.uptime}>{m.uptime > 0 ? `${m.uptime}%` : '—'}</span>
111
+ }
112
+
113
+ function AILatencyCellRenderer({ row }) {
114
+ const m = row.original
115
+ return <AILatencyCell result={m.benchmark || null} isRunning={Boolean(m.isBenchmarking)} />
116
+ }
117
+
118
+ function TPSCellRenderer({ row }) {
119
+ const m = row.original
120
+ return <TPSCell result={m.benchmark || null} isRunning={Boolean(m.isBenchmarking)} />
121
+ }
122
+
123
+ function TrendCellRenderer({ row }) {
124
+ return <Sparkline history={row.original.pingHistory} />
125
+ }
126
+
127
+ // ─── Column definitions ──────────────────────────────────────────────────────
128
+ const columns = [
129
+ colHelper.display({
130
+ id: 'mood',
131
+ header: '❔',
132
+ size: 28,
133
+ cell: MoodCellRenderer,
134
+ enableSorting: true,
135
+ }),
136
+ colHelper.accessor('idx', {
137
+ header: '#',
138
+ size: 36,
139
+ enableSorting: true,
140
+ cell: ({ getValue, row }) => (
141
+ <span className={styles.rankNum}>{getValue() ?? row.index + 1}</span>
142
+ ),
143
+ }),
144
+ colHelper.accessor('tier', {
145
+ header: 'Tier',
146
+ size: 48,
147
+ enableSorting: true,
148
+ cell: ({ getValue }) => <TierBadge tier={getValue()} />,
149
+ }),
150
+ colHelper.accessor('sweScore', {
151
+ header: 'SWE%',
152
+ size: 52,
153
+ enableSorting: true,
154
+ cell: SWECellRenderer,
155
+ }),
156
+ colHelper.accessor('ctx', {
157
+ header: 'CTX',
158
+ size: 48,
159
+ enableSorting: true,
160
+ cell: CtxCellRenderer,
161
+ }),
162
+ colHelper.accessor('label', {
163
+ header: 'Model',
164
+ size: 200,
165
+ enableSorting: true,
166
+ cell: ModelCellRenderer,
167
+ }),
168
+ colHelper.accessor('origin', {
169
+ header: 'Provider',
170
+ size: 110,
171
+ enableSorting: true,
172
+ cell: ProviderCellRenderer,
173
+ }),
174
+ colHelper.display({
175
+ id: 'latestPing',
176
+ header: 'Last Ping',
177
+ size: 80,
178
+ cell: LastPingCellRenderer,
179
+ enableSorting: true,
180
+ }),
181
+ colHelper.accessor('avg', {
182
+ header: 'Avg',
183
+ size: 72,
184
+ enableSorting: true,
185
+ cell: AvgPingCellRenderer,
186
+ }),
187
+ colHelper.accessor('status', {
188
+ id: 'condition',
189
+ header: 'Health',
190
+ size: 120,
191
+ enableSorting: true,
192
+ cell: HealthCellRenderer,
193
+ }),
194
+ colHelper.accessor('verdict', {
195
+ header: 'Verdict',
196
+ size: 100,
197
+ enableSorting: true,
198
+ cell: VerdictCellRenderer,
199
+ }),
200
+ colHelper.accessor('stability', {
201
+ header: 'Stability',
202
+ size: 90,
203
+ enableSorting: true,
204
+ cell: StabilityCellRenderer,
205
+ }),
206
+ colHelper.accessor('uptime', {
207
+ header: 'Up%',
208
+ size: 48,
209
+ enableSorting: true,
210
+ cell: UptimeCellRenderer,
211
+ }),
212
+ colHelper.display({
213
+ id: 'aiLatency',
214
+ header: 'AI Lat.',
215
+ size: 80,
216
+ cell: AILatencyCellRenderer,
217
+ enableSorting: true,
218
+ }),
219
+ colHelper.display({
220
+ id: 'tps',
221
+ header: 'TPS',
222
+ size: 48,
223
+ cell: TPSCellRenderer,
224
+ enableSorting: true,
225
+ }),
226
+ colHelper.display({
227
+ id: 'trend',
228
+ header: 'Trend',
229
+ size: 96,
230
+ cell: TrendCellRenderer,
231
+ enableSorting: true,
232
+ }),
233
+ ]
234
+
235
+ // ─── Sort icon component ────────────────────────────────────────────────────
236
+ function SortIcon({ column }) {
237
+ if (!column.getCanSort()) return null
238
+ const sorted = column.getIsSorted()
239
+ if (!sorted) return <span className={styles.sortIcon}>⇅</span>
240
+ return <span className={styles.sortIconActive}>{sorted === 'asc' ? '↑' : '↓'}</span>
241
+ }
242
+
243
+ // ─── Main component ───────────────────────────────────────────────────────────
244
+ export default function ModelTable({ filtered, onSelectModel, sortColumn, sortDirection, onSort }) {
245
+ // Compute top3 for medal rows
16
246
  const top3Ids = useMemo(() => {
17
- const online = filtered.filter(m => m.status === 'up' && m.avg !== Infinity)
247
+ const online = filtered.filter(m => m.status === 'up' && m.avg != null && m.avg !== Infinity && m.avg < 99000)
18
248
  return new Set([...online].sort((a, b) => a.avg - b.avg).slice(0, 3).map(m => m.modelId))
19
249
  }, [filtered])
20
250
 
21
- const top3Arr = useMemo(() => {
22
- const online = filtered.filter(m => m.status === 'up' && m.avg !== Infinity)
23
- return [...online].sort((a, b) => a.avg - b.avg).slice(0, 3).map(m => m.modelId)
24
- }, [filtered])
251
+ // TanStack Table no getSortedRowModel, sorting lives in useFilter
252
+ const table = useReactTable({
253
+ data: filtered,
254
+ columns,
255
+ defaultColumn: { enableSorting: true },
256
+ getCoreRowModel: getCoreRowModel(),
257
+ })
25
258
 
26
- if (filtered.length === 0) {
259
+ const rows = table.getRowModel().rows
260
+
261
+ if (rows.length === 0) {
27
262
  return <div className={styles.empty}>No models match your filters</div>
28
263
  }
29
264
 
@@ -31,51 +266,57 @@ export default function ModelTable({ filtered, onSelectModel }) {
31
266
  <div className={styles.container}>
32
267
  <table className={styles.table}>
33
268
  <thead>
34
- <tr>
35
- <th className={styles.th}>#</th>
36
- <th className={styles.th}>Tier</th>
37
- <th className={styles.th}>Model</th>
38
- <th className={styles.th}>Provider</th>
39
- <th className={styles.th}>SWE %</th>
40
- <th className={styles.th}>Ctx</th>
41
- <th className={styles.th}>Ping</th>
42
- <th className={styles.th}>Avg</th>
43
- <th className={styles.th}>Stability</th>
44
- <th className={styles.th}>Verdict</th>
45
- <th className={styles.th}>Uptime</th>
46
- <th className={styles.th}>Trend</th>
47
- </tr>
269
+ {table.getHeaderGroups().map(headerGroup => (
270
+ <tr key={headerGroup.id}>
271
+ {headerGroup.headers.map(header => {
272
+ const col = header.column
273
+ const canSort = SORTABLE_COLUMN_IDS.has(col.id)
274
+ return (
275
+ <th
276
+ key={header.id}
277
+ className={styles.th}
278
+ onClick={canSort ? () => onSort(header.id) : undefined}
279
+ style={{ cursor: canSort ? 'pointer' : 'default' }}
280
+ title={`Sort ${col.columnDef.header}: asc → desc → reset`}
281
+ >
282
+ {flexRender(col.columnDef.header, header.getContext())}
283
+ {canSort && (
284
+ <span className={
285
+ sortColumn === col.id
286
+ ? styles.sortIconActive
287
+ : styles.sortIcon
288
+ }>
289
+ {sortColumn === col.id
290
+ ? (sortDirection === 'asc' ? '↑' : '↓')
291
+ : '⇅'}
292
+ </span>
293
+ )}
294
+ </th>
295
+ )
296
+ })}
297
+ </tr>
298
+ ))}
48
299
  </thead>
49
300
  <tbody>
50
- {filtered.map((m, i) => {
51
- const rankIdx = top3Arr.indexOf(m.modelId)
52
- const medal = rankIdx === 0 ? '🥇' : rankIdx === 1 ? '🥈' : rankIdx === 2 ? '🥉' : ''
53
- const rowCls = rankIdx >= 0 ? styles[`rank${rankIdx + 1}`] : ''
301
+ {rows.map((row, i) => {
302
+ const m = row.original
303
+ const rankIdx = [...top3Ids].indexOf(m.modelId)
304
+ const rowClass = rankIdx >= 0 ? styles[`rank${rankIdx + 1}`] : ''
305
+ // 📖 Per-row benchmark state comes from the backend. The old global
306
+ // 📖 heuristic made every unbenchmarked row spin until the whole scan ended.
307
+ const isBenchRunning = Boolean(m.isBenchmarking)
308
+ const benchRowClass = isBenchRunning ? ` ${styles.benchRow}` : ''
54
309
  return (
55
- <tr key={m.modelId} className={rowCls} onClick={() => onSelectModel(m)}>
56
- <td className={styles.tdRank}>{medal || (i + 1)}</td>
57
- <td><TierBadge tier={m.tier} /></td>
58
- <td>
59
- <div className={styles.modelCell}>
60
- <StatusDot status={m.status} />
61
- <span className={styles.modelName}>{m.label}</span>
62
- {!m.hasApiKey && !m.cliOnly && <span className={styles.noKey}>🔑 NO KEY</span>}
63
- <div className={styles.modelId}>{m.modelId}</div>
64
- </div>
65
- </td>
66
- <td><span className={styles.providerPill}>{m.origin}</span></td>
67
- <td className={`${styles.swe} ${styles[sweClass(m.sweScore)]}`}>{m.sweScore || '—'}</td>
68
- <td className={styles.ctx}>{m.ctx || '—'}</td>
69
- <td className={`${styles.ping} ${styles[pingClass(m.latestPing)]}`}>
70
- {m.latestPing == null ? '—' : m.latestCode === '429' ? '429' : m.latestCode === '000' ? 'TIMEOUT' : `${m.latestPing}ms`}
71
- </td>
72
- <td className={`${styles.ping} ${styles[pingClass(m.avg)]}`}>
73
- {m.avg == null || m.avg === Infinity || m.avg > 99000 ? '—' : `${m.avg}ms`}
74
- </td>
75
- <td><StabilityCell score={m.stability} /></td>
76
- <td><VerdictBadge verdict={m.verdict} httpCode={m.httpCode} /></td>
77
- <td className={styles.uptime}>{m.uptime > 0 ? `${m.uptime}%` : '—'}</td>
78
- <td><Sparkline history={m.pingHistory} /></td>
310
+ <tr
311
+ key={row.id}
312
+ className={`${rowClass}${benchRowClass}`}
313
+ onClick={() => onSelectModel(row.original)}
314
+ >
315
+ {row.getVisibleCells().map(cell => (
316
+ <td key={cell.id} className={styles.td}>
317
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
318
+ </td>
319
+ ))}
79
320
  </tr>
80
321
  )
81
322
  })}
@@ -83,4 +324,4 @@ export default function ModelTable({ filtered, onSelectModel }) {
83
324
  </table>
84
325
  </div>
85
326
  )
86
- }
327
+ }
@@ -1,46 +1,144 @@
1
+ /**
2
+ * @file web/src/components/dashboard/ModelTable.module.css
3
+ * @description Styles for ModelTable with TanStack Table — full horizontal scroll.
4
+ */
1
5
  .container {
2
6
  background: var(--color-bg-card);
3
- backdrop-filter: blur(12px) saturate(1.5);
4
- -webkit-backdrop-filter: blur(12px) saturate(1.5);
5
- border: 1px solid var(--color-border);
6
- border-radius: 16px;
7
- overflow: hidden;
8
- margin: 16px 0;
9
- }
10
- .table { width: 100%; border-collapse: collapse; font-size: 13px; }
7
+ border: 1px solid var(--color-border-hover);
8
+ border-radius: var(--radius-lg);
9
+ margin: 16px;
10
+ flex: 1;
11
+ min-height: 0;
12
+ overflow: auto;
13
+ display: flex;
14
+ flex-direction: column;
15
+ }
16
+
17
+ .table {
18
+ width: 100%;
19
+ min-width: 1200px;
20
+ border-collapse: collapse;
21
+ font-size: 12px;
22
+ border: 1px solid var(--color-border-hover);
23
+ }
24
+
25
+ /* ─── Header ─── */
11
26
  .th {
12
- padding: 12px 14px; font-size: 11px; font-weight: 700; text-transform: uppercase;
13
- letter-spacing: 0.8px; color: var(--color-text-muted); background: var(--color-bg-elevated);
14
- border-bottom: 1px solid var(--color-border); white-space: nowrap; user-select: none; cursor: default;
27
+ padding: 9px 8px;
28
+ font-size: 10px;
29
+ font-weight: 700;
30
+ text-transform: uppercase;
31
+ letter-spacing: 0.5px;
32
+ color: var(--color-text-muted);
33
+ background: var(--color-bg-elevated);
34
+ border-bottom: 1px solid var(--color-border-hover);
35
+ border-right: 1px solid var(--color-border);
36
+ white-space: nowrap;
37
+ user-select: none;
38
+ position: sticky;
39
+ top: 0;
40
+ z-index: 10;
41
+ text-align: center;
42
+ }
43
+ .th:last-child { border-right: 0; }
44
+ .th:hover { color: var(--color-accent); background: var(--color-bg-active); }
45
+
46
+ /* Center certain columns */
47
+ .th:nth-child(6) { text-align: left; } /* Model */
48
+ .th:nth-child(7) { text-align: left; } /* Provider */
49
+ .th:nth-child(11) { text-align: left; } /* Verdict */
50
+
51
+ /* Sort icons */
52
+ .sortIcon { opacity: 0.3; margin-left: 3px; font-size: 9px; }
53
+ .sortIconActive { color: var(--color-accent); margin-left: 3px; font-size: 10px; font-weight: 800; }
54
+
55
+ /* ─── Data cells ─── */
56
+ .td {
57
+ padding: 7px 8px;
58
+ border-bottom: 1px solid var(--color-border);
59
+ border-right: 1px solid var(--color-border);
60
+ white-space: nowrap;
61
+ }
62
+ .td:last-child { border-right: 0; }
63
+ .td:nth-child(6) { text-align: left; } /* Model */
64
+ .td:nth-child(7) { text-align: left; } /* Provider */
65
+ .td:nth-child(11) { text-align: left; } /* Verdict */
66
+
67
+ /* ─── Row hover ─── */
68
+ .table tbody tr {
69
+ cursor: pointer;
70
+ transition: background 100ms;
15
71
  }
16
- .th:hover { color: var(--color-accent); }
17
- .table td { padding: 10px 14px; border-bottom: 1px solid var(--color-border); white-space: nowrap; }
18
- .table tbody tr { cursor: pointer; transition: background 150ms; }
19
72
  .table tbody tr:hover { background: var(--color-bg-hover); }
20
- .tdRank { text-align: center; font-family: var(--font-mono); font-weight: 600; color: var(--color-text-dim); }
73
+
74
+ /* ─── Medal rows ─── */
21
75
  .rank1 td:first-child { border-left: 3px solid #ffd700; }
22
76
  .rank2 td:first-child { border-left: 3px solid #c0c0c0; }
23
77
  .rank3 td:first-child { border-left: 3px solid #cd7f32; }
24
- .modelCell { display: flex; flex-direction: column; }
25
- .modelName { font-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 4px; }
26
- .modelId { font-family: var(--font-mono); font-size: 10px; color: var(--color-text-dim); margin-top: 1px; }
27
- .noKey {
28
- font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 3px;
29
- background: rgba(255,170,0,0.12); color: #ffaa00; border: 1px solid rgba(255,170,0,0.2); margin-left: 6px;
30
- }
31
- .providerPill {
32
- display: inline-block; font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: 999px;
33
- background: var(--color-accent-dim); color: var(--color-text-muted); border: 1px solid var(--color-border);
78
+
79
+ /* ─── Shared cell values ─── */
80
+ .rankNum {
81
+ font-family: var(--font-mono);
82
+ font-size: 11px;
83
+ color: var(--color-text-dim);
84
+ text-align: center;
85
+ display: block;
34
86
  }
87
+
35
88
  .swe { font-family: var(--font-mono); font-weight: 600; }
36
89
  .sweHigh { color: #ffd700; }
37
- .sweMid { color: #3ddc84; }
38
- .sweLow { color: var(--color-text-dim); }
39
- .ctx { font-family: var(--font-mono); font-size: 12px; color: var(--color-text-muted); }
90
+ .sweMid { color: #3ddc84; }
91
+ .sweLow { color: var(--color-text-dim); }
92
+
93
+ .ctx { font-family: var(--font-mono); font-size: 11px; }
94
+
40
95
  .ping { font-family: var(--font-mono); font-weight: 600; }
41
- .pingFast { color: #00ff88; }
96
+ .pingFast { color: #00ff88; }
42
97
  .pingMedium { color: #ffaa00; }
43
- .pingSlow { color: #ff4444; }
44
- .pingNone { color: var(--color-text-dim); }
45
- .uptime { font-family: var(--font-mono); font-size: 12px; }
46
- .empty { text-align: center; padding: 60px 0; color: var(--color-text-muted); }
98
+ .pingSlow { color: #ff4444; }
99
+ .pingNone { color: var(--color-text-dim); }
100
+
101
+ .uptime { font-family: var(--font-mono); font-size: 11px; }
102
+
103
+ /* ─── Model cell ─── */
104
+ .modelCell { display: flex; align-items: center; gap: 6px; overflow: hidden; }
105
+ .modelMeta { flex: 1; min-width: 0; }
106
+ .modelHeader { display: flex; align-items: center; gap: 4px; }
107
+ .modelName { font-weight: 600; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
108
+ .modelId { font-family: var(--font-mono); font-size: 9px; color: var(--color-text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
109
+ .noKey {
110
+ font-size: 9px; font-weight: 600; padding: 1px 5px; border-radius: 3px; flex-shrink: 0;
111
+ background: rgba(255,170,0,0.12); color: #ffaa00; border: 1px solid rgba(255,170,0,0.2);
112
+ }
113
+
114
+ /* ─── Provider pill ─── */
115
+ .providerPill {
116
+ display: inline-block; font-size: 10px; font-weight: 500; padding: 2px 7px; border-radius: 999px;
117
+ background: var(--color-accent-dim); color: var(--color-text-muted);
118
+ border: 1px solid var(--color-border); white-space: nowrap;
119
+ }
120
+
121
+ /* ─── Benchmarking row highlight ── */
122
+ @keyframes benchRowPulse {
123
+ 0%, 100% { background: rgba(180, 0, 255, 0.04); }
124
+ 50% { background: rgba(180, 0, 255, 0.10); }
125
+ }
126
+ .benchRow {
127
+ animation: benchRowPulse 1.2s ease-in-out infinite;
128
+ }
129
+
130
+ /* ─── Empty state ─── */
131
+ :global([data-theme="light"]) .container {
132
+ border-color: #cfcfcf;
133
+ }
134
+
135
+ :global([data-theme="light"]) .table {
136
+ border-color: #cfcfcf;
137
+ }
138
+
139
+ :global([data-theme="light"]) .th,
140
+ :global([data-theme="light"]) .td {
141
+ border-color: #d8d8d8;
142
+ }
143
+
144
+ .empty { text-align: center; padding: 60px 0; color: var(--color-text-muted); }
@@ -3,6 +3,7 @@
3
3
  * @description Stats cards row showing total models, online count, avg latency, fastest model, providers.
4
4
  */
5
5
  import { useMemo } from 'react'
6
+ import { IconServer, IconCircleCheck, IconBolt, IconTrophy, IconGlobe } from '@tabler/icons-react'
6
7
  import styles from './StatsBar.module.css'
7
8
 
8
9
  export default function StatsBar({ models }) {
@@ -16,11 +17,11 @@ export default function StatsBar({ models }) {
16
17
  const fastest = [...onlineWithPing].sort((a, b) => a.avg - b.avg)[0]
17
18
  const providers = new Set(models.map(m => m.providerKey)).size
18
19
  return [
19
- { icon: '📊', value: total, label: 'Total Models' },
20
- { icon: '🟢', value: online, label: 'Online' },
21
- { icon: '⚡', value: avgLatency != null ? `${avgLatency}ms` : '—', label: 'Avg Latency' },
22
- { icon: '🏆', value: fastest ? fastest.label : '—', label: 'Fastest Model' },
23
- { icon: '🌐', value: providers, label: 'Providers' },
20
+ { icon: <IconServer size={20} stroke={1.5} />, value: total, label: 'Total Models' },
21
+ { icon: <IconCircleCheck size={20} stroke={1.5} />, value: online, label: 'Online' },
22
+ { icon: <IconBolt size={20} stroke={1.5} />, value: avgLatency != null ? `${avgLatency}ms` : '—', label: 'Avg Latency' },
23
+ { icon: <IconTrophy size={20} stroke={1.5} />, value: fastest ? fastest.label : '—', label: 'Fastest Model' },
24
+ { icon: <IconGlobe size={20} stroke={1.5} />, value: providers, label: 'Providers' },
24
25
  ]
25
26
  }, [models])
26
27
 
@@ -38,3 +39,4 @@ export default function StatsBar({ models }) {
38
39
  </section>
39
40
  )
40
41
  }
42
+
@@ -8,7 +8,7 @@ export default function Footer() {
8
8
  return (
9
9
  <footer className={styles.footer}>
10
10
  <div className={styles.left}>
11
- Made with ❤️ by <a href="https://vavanessa.dev" target="_blank" rel="noopener">Vava-Nessa</a>
11
+ by <a href="https://vavanessa.dev" target="_blank" rel="noopener">Vava-Nessa</a>
12
12
  </div>
13
13
  <div className={styles.right}>
14
14
  <a href="https://github.com/vava-nessa/free-coding-models" target="_blank" rel="noopener">GitHub</a>
@@ -1,22 +1,32 @@
1
1
  /**
2
2
  * @file web/src/components/layout/Header.jsx
3
- * @description Top header bar with search, export button, settings shortcut, and theme toggle.
3
+ * @description Top header bar with search, AI Latency benchmark button, export, settings, and theme toggle.
4
+ * 📖 AI Latency button benchmarks the models currently visible after filters/search.
4
5
  */
6
+ import { IconBolt, IconSearch, IconDownload, IconSettings, IconMoon, IconSun, IconPlayerPlay } from '@tabler/icons-react'
5
7
  import styles from './Header.module.css'
6
8
 
7
- export default function Header({ searchQuery, onSearchChange, onToggleTheme, onOpenSettings, onOpenExport }) {
9
+ export default function Header({
10
+ searchQuery, onSearchChange,
11
+ onToggleTheme, onOpenSettings, onOpenExport,
12
+ onBenchmark, benchmarkRunning, benchmarkTotal, benchmarkCompleted, modelsCount, theme,
13
+ }) {
8
14
  return (
9
15
  <header className={styles.header}>
10
16
  <div className={styles.left}>
11
17
  <div className={styles.logo}>
12
- <span className={styles.logoIcon}>⚡</span>
13
- <span className={styles.logoText}>free-coding-models</span>
18
+ <span className={styles.logoIcon}>&gt;</span>
19
+ <span className={styles.logoText}>
20
+ <span className={styles.logoTextHighlight}>free</span>
21
+ <span>-coding-models</span>
22
+ <span className={styles.logoTextHighlight}>_</span>
23
+ </span>
14
24
  </div>
15
25
  <span className={styles.version}>v{__APP_VERSION__}</span>
16
26
  </div>
17
27
  <div className={styles.center}>
18
28
  <div className={styles.searchBar}>
19
- <span className={styles.searchIcon}>🔍</span>
29
+ <span className={styles.searchIcon}><IconSearch size={16} stroke={1.5} /></span>
20
30
  <input
21
31
  type="text"
22
32
  className={styles.searchInput}
@@ -29,10 +39,34 @@ export default function Header({ searchQuery, onSearchChange, onToggleTheme, onO
29
39
  </div>
30
40
  </div>
31
41
  <div className={styles.right}>
32
- <button className={styles.iconBtn} onClick={onToggleTheme} title="Toggle theme">☽</button>
33
- <button className={styles.iconBtn} onClick={onOpenExport} title="Export Data">↓</button>
34
- <button className={styles.primaryBtn} onClick={onOpenSettings}>⚙ Settings</button>
42
+ {/* AI Latency Benchmark — always visible, shows model count when idle */}
43
+ <button
44
+ className={`${styles.benchmarkBtn} ${benchmarkRunning ? styles.benchmarkActive : ''}`}
45
+ onClick={onBenchmark}
46
+ disabled={benchmarkRunning}
47
+ title={benchmarkRunning ? `AI Speed Test running — ${benchmarkCompleted}/${benchmarkTotal}` : `Run AI Latency benchmark on ${modelsCount} visible models`}
48
+ >
49
+ <IconPlayerPlay size={14} stroke={1.5} />
50
+ {benchmarkRunning ? (
51
+ <span className={styles.benchmarkRunning}>
52
+ <span className={styles.spinner} />
53
+ RUN {benchmarkCompleted}/{benchmarkTotal}
54
+ </span>
55
+ ) : (
56
+ <span>AI Latency</span>
57
+ )}
58
+ </button>
59
+
60
+ <button className={styles.iconBtn} onClick={onToggleTheme} title="Toggle theme">
61
+ {theme === 'light' ? <IconMoon size={16} stroke={1.5} /> : <IconSun size={16} stroke={1.5} />}
62
+ </button>
63
+ <button className={styles.iconBtn} onClick={onOpenExport} title="Export Data">
64
+ <IconDownload size={16} stroke={1.5} />
65
+ </button>
66
+ <button className={styles.primaryBtn} onClick={onOpenSettings}>
67
+ <IconSettings size={16} stroke={1.5} /> Settings
68
+ </button>
35
69
  </div>
36
70
  </header>
37
71
  )
38
- }
72
+ }