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.
- package/README.md +9 -1
- package/bin/free-coding-models.js +10 -0
- package/changelog/v0.5.1.md +24 -0
- package/package.json +7 -2
- 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/render-table.js +1 -1
- package/web/README.md +8 -5
- package/web/dist/assets/index-ByGf4Kq-.js +14 -0
- package/web/dist/assets/index-Ds7wmHBv.css +1 -0
- package/web/dist/index.html +3 -6
- package/web/index.html +1 -4
- package/web/package.json +11 -0
- package/web/server.js +606 -211
- package/web/src/App.jsx +54 -12
- package/web/src/components/analytics/AnalyticsView.jsx +10 -4
- package/web/src/components/atoms/AILatencyCell.jsx +38 -0
- package/web/src/components/atoms/AILatencyCell.module.css +43 -0
- package/web/src/components/atoms/HealthCell.jsx +53 -0
- package/web/src/components/atoms/HealthCell.module.css +15 -0
- package/web/src/components/atoms/LastPingCell.jsx +35 -0
- package/web/src/components/atoms/LastPingCell.module.css +35 -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/RankCell.jsx +9 -0
- package/web/src/components/atoms/RankCell.module.css +9 -0
- package/web/src/components/atoms/TPSCell.jsx +36 -0
- package/web/src/components/atoms/TPSCell.module.css +38 -0
- package/web/src/components/atoms/VerdictBadge.jsx +30 -7
- package/web/src/components/atoms/VerdictBadge.module.css +24 -15
- package/web/src/components/dashboard/ExportModal.jsx +9 -4
- package/web/src/components/dashboard/FilterBar.jsx +112 -10
- package/web/src/components/dashboard/FilterBar.module.css +86 -1
- package/web/src/components/dashboard/ModelTable.jsx +293 -52
- package/web/src/components/dashboard/ModelTable.module.css +131 -33
- package/web/src/components/dashboard/StatsBar.jsx +7 -5
- package/web/src/components/layout/Footer.jsx +1 -1
- package/web/src/components/layout/Header.jsx +43 -9
- package/web/src/components/layout/Header.module.css +38 -4
- package/web/src/components/layout/Sidebar.jsx +19 -11
- package/web/src/components/layout/Sidebar.module.css +15 -5
- package/web/src/components/settings/SettingsView.jsx +24 -6
- package/web/src/components/settings/SettingsView.module.css +0 -1
- package/web/src/global.css +70 -73
- package/web/src/hooks/useFilter.js +117 -25
- package/web/src/hooks/useSSE.js +33 -9
- package/web/src/hooks/useSocket.js +200 -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
|
@@ -1,29 +1,264 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file web/src/components/dashboard/ModelTable.jsx
|
|
3
|
-
* @description Main data table with
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
{
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
{
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
4
|
-
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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:
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
.
|
|
27
|
-
|
|
28
|
-
font-size:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
38
|
-
.sweLow
|
|
39
|
-
|
|
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
|
|
96
|
+
.pingFast { color: #00ff88; }
|
|
42
97
|
.pingMedium { color: #ffaa00; }
|
|
43
|
-
.pingSlow
|
|
44
|
-
.pingNone
|
|
45
|
-
|
|
46
|
-
.
|
|
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:
|
|
20
|
-
{ icon:
|
|
21
|
-
{ icon:
|
|
22
|
-
{ icon:
|
|
23
|
-
{ icon:
|
|
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
|
-
|
|
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,
|
|
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({
|
|
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}
|
|
13
|
-
<span className={styles.logoText}>
|
|
18
|
+
<span className={styles.logoIcon}>></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}
|
|
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
|
-
|
|
33
|
-
<button
|
|
34
|
-
|
|
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
|
+
}
|