free-coding-models 0.5.1 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -5
- package/changelog/v0.5.4.md +24 -0
- package/package.json +5 -2
- package/sources.js +2 -0
- package/src/tui/key-handler.js +1 -1
- package/web/assets/providers/aistudio/aistudio-text.svg +1 -0
- package/web/assets/providers/aistudio/aistudio.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-brand-color.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-brand.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-color.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-text.svg +1 -0
- package/web/assets/providers/cerebras/cerebras.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare-color.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare-text.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare.svg +1 -0
- package/web/assets/providers/gemini/gemini-color.svg +1 -0
- package/web/assets/providers/gemini/gemini-text.svg +1 -0
- package/web/assets/providers/gemini/gemini.svg +1 -0
- package/web/assets/providers/github/github-text.svg +1 -0
- package/web/assets/providers/github/github.svg +1 -0
- package/web/assets/providers/groq/groq-text.svg +1 -0
- package/web/assets/providers/groq/groq.svg +1 -0
- package/web/assets/providers/mistral/mistral-color.svg +1 -0
- package/web/assets/providers/mistral/mistral-text.svg +1 -0
- package/web/assets/providers/mistral/mistral.svg +1 -0
- package/web/assets/providers/nvidia/nvidia-color.svg +1 -0
- package/web/assets/providers/nvidia/nvidia-text.svg +1 -0
- package/web/assets/providers/nvidia/nvidia.svg +1 -0
- package/web/assets/providers/opencode/opencode-text.svg +1 -0
- package/web/assets/providers/opencode/opencode.svg +1 -0
- package/web/assets/providers/openrouter/openrouter (1).svg +1 -0
- package/web/assets/providers/openrouter/openrouter-text.svg +1 -0
- package/web/assets/providers/openrouter/openrouter.svg +1 -0
- package/web/assets/providers/ovhcloud/ovhcloud.svg +12 -0
- package/web/assets/providers/qwen/qwen-color.svg +1 -0
- package/web/assets/providers/qwen/qwen-text.svg +1 -0
- package/web/assets/providers/qwen/qwen.svg +1 -0
- package/web/assets/providers/sambanova/sambanova-color.svg +1 -0
- package/web/assets/providers/sambanova/sambanova-text.svg +1 -0
- package/web/assets/providers/sambanova/sambanova.svg +1 -0
- package/web/assets/providers/scalewaylogo/ScalewayLogo.svg +13 -0
- package/web/assets/providers/zai/zai-text.svg +1 -0
- package/web/assets/providers/zai/zai.svg +1 -0
- package/web/dist/assets/index-BoWmUveV.js +39 -0
- package/web/dist/assets/index-BrpHevg4.css +1 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/favicons/apple-touch-icon.png +0 -0
- package/web/dist/favicons/browserconfig.xml +12 -0
- package/web/dist/favicons/favicon-16x16.png +0 -0
- package/web/dist/favicons/favicon-192x192.png +0 -0
- package/web/dist/favicons/favicon-32x32.png +0 -0
- package/web/dist/favicons/favicon-48x48.png +0 -0
- package/web/dist/favicons/favicon-512x512.png +0 -0
- package/web/dist/favicons/favicon-96x96.png +0 -0
- package/web/dist/favicons/favicon.ico +0 -0
- package/web/dist/favicons/mstile-150x150.png +0 -0
- package/web/dist/favicons/mstile-310x150.png +0 -0
- package/web/dist/favicons/mstile-310x310.png +0 -0
- package/web/dist/favicons/mstile-512x512.png +0 -0
- package/web/dist/favicons/mstile-70x70.png +0 -0
- package/web/dist/favicons/site.webmanifest +25 -0
- package/web/dist/index.html +42 -3
- package/web/index.html +40 -1
- package/web/public/favicon.ico +0 -0
- package/web/public/favicons/apple-touch-icon.png +0 -0
- package/web/public/favicons/browserconfig.xml +12 -0
- package/web/public/favicons/favicon-16x16.png +0 -0
- package/web/public/favicons/favicon-192x192.png +0 -0
- package/web/public/favicons/favicon-32x32.png +0 -0
- package/web/public/favicons/favicon-48x48.png +0 -0
- package/web/public/favicons/favicon-512x512.png +0 -0
- package/web/public/favicons/favicon-96x96.png +0 -0
- package/web/public/favicons/favicon.ico +0 -0
- package/web/public/favicons/mstile-150x150.png +0 -0
- package/web/public/favicons/mstile-310x150.png +0 -0
- package/web/public/favicons/mstile-310x310.png +0 -0
- package/web/public/favicons/mstile-512x512.png +0 -0
- package/web/public/favicons/mstile-70x70.png +0 -0
- package/web/public/favicons/site.webmanifest +25 -0
- package/web/server.js +71 -1
- package/web/src/App.jsx +209 -89
- package/web/src/components/analytics/AnalyticsView.jsx +30 -2
- package/web/src/components/analytics/AnalyticsView.module.css +4 -0
- package/web/src/components/atoms/AILatencyCell.module.css +9 -1
- package/web/src/components/atoms/HealthCell.jsx +113 -28
- package/web/src/components/atoms/HealthCell.module.css +43 -2
- package/web/src/components/atoms/LastPingCell.module.css +10 -0
- package/web/src/components/atoms/NoKeyIcon.jsx +65 -0
- package/web/src/components/atoms/NoKeyIcon.module.css +30 -0
- package/web/src/components/atoms/ProviderLogo.jsx +188 -0
- package/web/src/components/atoms/ProviderLogo.module.css +103 -0
- package/web/src/components/atoms/Sparkline.jsx +21 -2
- package/web/src/components/atoms/StabilityCell.module.css +5 -0
- package/web/src/components/atoms/StatusDot.module.css +7 -0
- package/web/src/components/atoms/TPSCell.module.css +7 -1
- package/web/src/components/atoms/TierBadge.module.css +11 -0
- package/web/src/components/atoms/Toast.module.css +6 -0
- package/web/src/components/atoms/VerdictBadge.module.css +12 -0
- package/web/src/components/dashboard/DetailPanel.jsx +63 -2
- package/web/src/components/dashboard/DetailPanel.module.css +130 -0
- package/web/src/components/dashboard/FilterBar.jsx +146 -53
- package/web/src/components/dashboard/FilterBar.module.css +138 -58
- package/web/src/components/dashboard/ModelTable.jsx +235 -30
- package/web/src/components/dashboard/ModelTable.module.css +205 -16
- package/web/src/components/layout/Header.jsx +140 -12
- package/web/src/components/layout/Header.module.css +191 -27
- package/web/src/components/palette/CommandPalette.jsx +146 -0
- package/web/src/components/palette/CommandPalette.module.css +121 -0
- package/web/src/global.css +52 -15
- package/web/src/hooks/useColumnSizing.js +211 -0
- package/web/src/hooks/useFavorites.js +150 -0
- package/web/src/hooks/useFilter.js +157 -24
- package/web/src/hooks/useTheme.js +72 -16
- package/web/src/hooks/useUrlState.js +93 -0
- package/web/dist/assets/index-ByGf4Kq-.js +0 -14
- package/web/dist/assets/index-Ds7wmHBv.css +0 -1
- package/web/src/components/dashboard/StatsBar.jsx +0 -42
- package/web/src/components/dashboard/StatsBar.module.css +0 -28
- package/web/src/components/layout/Sidebar.jsx +0 -50
- package/web/src/components/layout/Sidebar.module.css +0 -86
- package/web/src/components/map/MapView.jsx +0 -17
- package/web/src/components/map/MapView.module.css +0 -25
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
* @description Slide-in detail panel showing full model stats, metrics, and a latency chart.
|
|
4
4
|
* 📖 Rendered as a fixed overlay on the right side. Controlled by `model` prop (null = hidden).
|
|
5
5
|
* Displays model ID, provider, tier, SWE score, context, status, pings, stability, verdict, uptime,
|
|
6
|
-
* ping count, API key status,
|
|
6
|
+
* ping count, API key status, a larger SVG latency trend chart, and an M1 favorites + benchmark
|
|
7
|
+
* 📖 action section (star + reorder + per-row AI Speed Test button).
|
|
7
8
|
* @functions DetailPanel → main panel component, buildDetailChart → SVG chart builder
|
|
8
9
|
*/
|
|
9
10
|
import { useMemo } from 'react'
|
|
11
|
+
import { IconStar, IconStarFilled, IconChevronUp, IconChevronDown, IconPlayerPlayFilled } from '@tabler/icons-react'
|
|
10
12
|
import TierBadge from '../atoms/TierBadge.jsx'
|
|
11
13
|
import VerdictBadge from '../atoms/VerdictBadge.jsx'
|
|
12
14
|
import StatusDot from '../atoms/StatusDot.jsx'
|
|
@@ -68,10 +70,14 @@ function StatRow({ label, children }) {
|
|
|
68
70
|
)
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
export default function DetailPanel({ model, onClose }) {
|
|
73
|
+
export default function DetailPanel({ model, onClose, favorites, onBenchmark, onToast }) {
|
|
72
74
|
if (!model) return null
|
|
73
75
|
|
|
74
76
|
const chartSvg = buildDetailChart(model.pingHistory)
|
|
77
|
+
const isFav = favorites?.isFavorite(model) ?? false
|
|
78
|
+
const favRank = favorites?.favoriteRank(model) ?? Number.MAX_SAFE_INTEGER
|
|
79
|
+
const isFavAtTop = isFav && favRank === 0
|
|
80
|
+
const isFavAtBottom = isFav && favRank === (favorites?.favorites.length ?? 0) - 1
|
|
75
81
|
|
|
76
82
|
return (
|
|
77
83
|
<div className={styles.panel}>
|
|
@@ -121,6 +127,61 @@ export default function DetailPanel({ model, onClose }) {
|
|
|
121
127
|
<StatRow label="API Key">
|
|
122
128
|
{model.hasApiKey ? '✅ Configured' : '❌ Missing'}
|
|
123
129
|
</StatRow>
|
|
130
|
+
|
|
131
|
+
{/* ── M1: favorites + per-row benchmark action section ── */}
|
|
132
|
+
{(favorites || onBenchmark) && (
|
|
133
|
+
<div className={styles.actions}>
|
|
134
|
+
{favorites && (
|
|
135
|
+
<div className={styles.favBlock}>
|
|
136
|
+
<button
|
|
137
|
+
className={`${styles.favBtn} ${isFav ? styles.favBtnActive : ''}`}
|
|
138
|
+
onClick={() => favorites.toggle(model)}
|
|
139
|
+
title={isFav ? `Unfavorite ${model.label} (TUI: F)` : `Favorite ${model.label} (TUI: F)`}
|
|
140
|
+
>
|
|
141
|
+
{isFav ? <IconStarFilled size={14} stroke={1.5} /> : <IconStar size={14} stroke={1.5} />}
|
|
142
|
+
<span>{isFav ? 'Favorited' : 'Add to favorites'}</span>
|
|
143
|
+
</button>
|
|
144
|
+
{isFav && (
|
|
145
|
+
<div className={styles.reorderRow}>
|
|
146
|
+
<button
|
|
147
|
+
className={styles.reorderBtn}
|
|
148
|
+
disabled={isFavAtTop}
|
|
149
|
+
onClick={() => favorites.reorder(model, 'up')}
|
|
150
|
+
title="Move up in router priority (TUI: Shift+↑)"
|
|
151
|
+
aria-label="Move favorite up"
|
|
152
|
+
>
|
|
153
|
+
<IconChevronUp size={12} stroke={1.5} /> Up
|
|
154
|
+
</button>
|
|
155
|
+
<button
|
|
156
|
+
className={styles.reorderBtn}
|
|
157
|
+
disabled={isFavAtBottom}
|
|
158
|
+
onClick={() => favorites.reorder(model, 'down')}
|
|
159
|
+
title="Move down in router priority (TUI: Shift+↓)"
|
|
160
|
+
aria-label="Move favorite down"
|
|
161
|
+
>
|
|
162
|
+
<IconChevronDown size={12} stroke={1.5} /> Down
|
|
163
|
+
</button>
|
|
164
|
+
<span className={styles.rankBadge}>#{favRank + 1}</span>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
{onBenchmark && (
|
|
170
|
+
<button
|
|
171
|
+
className={styles.benchBtn}
|
|
172
|
+
onClick={() => {
|
|
173
|
+
onBenchmark(model)
|
|
174
|
+
onToast?.(`Benchmark started for ${model.label}…`, 'info')
|
|
175
|
+
}}
|
|
176
|
+
title="Run AI Speed Test on this model (TUI: Ctrl+A)"
|
|
177
|
+
>
|
|
178
|
+
<IconPlayerPlayFilled size={12} stroke={1.5} />
|
|
179
|
+
<span>AI Speed Test</span>
|
|
180
|
+
</button>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
|
|
124
185
|
<div className={styles.chart}>
|
|
125
186
|
<div className={styles.chartTitle}>Latency Trend (last 20 pings)</div>
|
|
126
187
|
{chartSvg}
|
|
@@ -75,6 +75,10 @@
|
|
|
75
75
|
.pingSlow { color: #ff4444; }
|
|
76
76
|
.pingNone { color: var(--color-text-dim); }
|
|
77
77
|
|
|
78
|
+
:global([data-theme="light"]) .pingFast { color: #008f4d; }
|
|
79
|
+
:global([data-theme="light"]) .pingMedium { color: #b86b00; }
|
|
80
|
+
:global([data-theme="light"]) .pingSlow { color: #c8143a; }
|
|
81
|
+
|
|
78
82
|
.chart {
|
|
79
83
|
margin: 20px 0;
|
|
80
84
|
padding: 16px;
|
|
@@ -97,3 +101,129 @@
|
|
|
97
101
|
text-align: center;
|
|
98
102
|
padding: 20px;
|
|
99
103
|
}
|
|
104
|
+
|
|
105
|
+
/* ─── M1: favorites + per-row benchmark actions ─── */
|
|
106
|
+
.actions {
|
|
107
|
+
display: flex;
|
|
108
|
+
flex-direction: column;
|
|
109
|
+
gap: 10px;
|
|
110
|
+
margin: 16px 0;
|
|
111
|
+
padding: 12px;
|
|
112
|
+
background: var(--color-surface);
|
|
113
|
+
border-radius: var(--radius-md);
|
|
114
|
+
border: 1px solid var(--color-border);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.favBlock {
|
|
118
|
+
display: flex;
|
|
119
|
+
flex-direction: column;
|
|
120
|
+
gap: 8px;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.favBtn {
|
|
124
|
+
display: inline-flex;
|
|
125
|
+
align-items: center;
|
|
126
|
+
gap: 8px;
|
|
127
|
+
padding: 8px 12px;
|
|
128
|
+
background: var(--color-bg-card);
|
|
129
|
+
color: var(--color-text-muted);
|
|
130
|
+
border: 1px solid var(--color-border);
|
|
131
|
+
border-radius: 6px;
|
|
132
|
+
font-size: 12px;
|
|
133
|
+
font-weight: 600;
|
|
134
|
+
cursor: pointer;
|
|
135
|
+
transition: all 150ms;
|
|
136
|
+
text-align: left;
|
|
137
|
+
}
|
|
138
|
+
.favBtn:hover {
|
|
139
|
+
border-color: #ffd700;
|
|
140
|
+
color: #ffd700;
|
|
141
|
+
background: rgba(255, 215, 0, 0.08);
|
|
142
|
+
}
|
|
143
|
+
.favBtnActive {
|
|
144
|
+
border-color: #ffd700;
|
|
145
|
+
color: #ffd700;
|
|
146
|
+
background: rgba(255, 215, 0, 0.12);
|
|
147
|
+
}
|
|
148
|
+
.favBtnActive:hover {
|
|
149
|
+
background: rgba(255, 215, 0, 0.16);
|
|
150
|
+
}
|
|
151
|
+
:global([data-theme="light"]) .favBtn { color: #555; }
|
|
152
|
+
:global([data-theme="light"]) .favBtn:hover { color: #b08800; border-color: #b08800; background: rgba(176, 136, 0, 0.06); }
|
|
153
|
+
:global([data-theme="light"]) .favBtnActive { color: #b08800; border-color: #b08800; background: rgba(176, 136, 0, 0.10); }
|
|
154
|
+
|
|
155
|
+
.reorderRow {
|
|
156
|
+
display: flex;
|
|
157
|
+
align-items: center;
|
|
158
|
+
gap: 6px;
|
|
159
|
+
padding: 0 4px;
|
|
160
|
+
}
|
|
161
|
+
.reorderBtn {
|
|
162
|
+
display: inline-flex;
|
|
163
|
+
align-items: center;
|
|
164
|
+
gap: 3px;
|
|
165
|
+
padding: 4px 8px;
|
|
166
|
+
font-size: 10px;
|
|
167
|
+
font-weight: 600;
|
|
168
|
+
font-family: var(--font-mono);
|
|
169
|
+
background: var(--color-bg-card);
|
|
170
|
+
color: var(--color-text-muted);
|
|
171
|
+
border: 1px solid var(--color-border);
|
|
172
|
+
border-radius: 4px;
|
|
173
|
+
cursor: pointer;
|
|
174
|
+
transition: all 120ms;
|
|
175
|
+
}
|
|
176
|
+
.reorderBtn:hover:not(:disabled) {
|
|
177
|
+
background: var(--color-bg-hover);
|
|
178
|
+
color: var(--color-text);
|
|
179
|
+
border-color: var(--color-text-muted);
|
|
180
|
+
}
|
|
181
|
+
.reorderBtn:disabled {
|
|
182
|
+
opacity: 0.4;
|
|
183
|
+
cursor: not-allowed;
|
|
184
|
+
}
|
|
185
|
+
.rankBadge {
|
|
186
|
+
margin-left: auto;
|
|
187
|
+
font-size: 10px;
|
|
188
|
+
font-family: var(--font-mono);
|
|
189
|
+
font-weight: 700;
|
|
190
|
+
color: #ffd700;
|
|
191
|
+
background: rgba(255, 215, 0, 0.12);
|
|
192
|
+
border: 1px solid rgba(255, 215, 0, 0.3);
|
|
193
|
+
padding: 2px 6px;
|
|
194
|
+
border-radius: 999px;
|
|
195
|
+
}
|
|
196
|
+
:global([data-theme="light"]) .rankBadge {
|
|
197
|
+
color: #b08800;
|
|
198
|
+
background: rgba(176, 136, 0, 0.12);
|
|
199
|
+
border-color: rgba(176, 136, 0, 0.3);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.benchBtn {
|
|
203
|
+
display: inline-flex;
|
|
204
|
+
align-items: center;
|
|
205
|
+
justify-content: center;
|
|
206
|
+
gap: 6px;
|
|
207
|
+
padding: 8px 12px;
|
|
208
|
+
font-size: 12px;
|
|
209
|
+
font-weight: 700;
|
|
210
|
+
font-family: var(--font-mono);
|
|
211
|
+
background: rgba(180, 0, 255, 0.12);
|
|
212
|
+
color: #b400ff;
|
|
213
|
+
border: 1px solid rgba(180, 0, 255, 0.4);
|
|
214
|
+
border-radius: 6px;
|
|
215
|
+
cursor: pointer;
|
|
216
|
+
transition: all 150ms;
|
|
217
|
+
}
|
|
218
|
+
.benchBtn:hover {
|
|
219
|
+
background: rgba(180, 0, 255, 0.20);
|
|
220
|
+
border-color: rgba(180, 0, 255, 0.7);
|
|
221
|
+
}
|
|
222
|
+
:global([data-theme="light"]) .benchBtn {
|
|
223
|
+
background: rgba(140, 0, 200, 0.06);
|
|
224
|
+
color: #6a00a0;
|
|
225
|
+
border-color: rgba(140, 0, 200, 0.3);
|
|
226
|
+
}
|
|
227
|
+
:global([data-theme="light"]) .benchBtn:hover {
|
|
228
|
+
background: rgba(140, 0, 200, 0.12);
|
|
229
|
+
}
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file web/src/components/dashboard/FilterBar.jsx
|
|
3
|
-
* @description Filter controls for
|
|
4
|
-
* 📖
|
|
3
|
+
* @description Filter controls for the model table — TUI parity chips.
|
|
4
|
+
* 📖 M1 parity: tier (T) / status / provider (D) / verdict (V) / health (H) / visibility (E)
|
|
5
|
+
* 📖 + custom text filter chip with "X" clear + reset view button (N).
|
|
6
|
+
* 📖 Each chip is a cycling button matching the TUI single-key behavior.
|
|
7
|
+
* 📖 The "Next ping in Xs" countdown still shows the live status.
|
|
5
8
|
*/
|
|
6
|
-
import { useState, useEffect } from 'react'
|
|
9
|
+
import { useState, useEffect, useMemo } from 'react'
|
|
10
|
+
import { IconRefresh, IconX, IconFilter, IconCircleCheck, IconAlertTriangle, IconActivity, IconEye, IconStar } from '@tabler/icons-react'
|
|
7
11
|
import styles from './FilterBar.module.css'
|
|
8
12
|
|
|
13
|
+
// 📖 Chip sets match the TUI cycles 1:1 (see useFilter.js). Keep these in sync.
|
|
9
14
|
const TIERS = [
|
|
10
15
|
{ key: 'all', label: 'All' },
|
|
11
16
|
{ key: 'S+', label: 'S+' },
|
|
@@ -19,10 +24,36 @@ const TIERS = [
|
|
|
19
24
|
]
|
|
20
25
|
const STATUSES = [
|
|
21
26
|
{ key: 'all', label: 'All' },
|
|
22
|
-
{ key: 'up', label: '
|
|
23
|
-
{ key: 'down', label: '
|
|
27
|
+
{ key: 'up', label: 'Up' },
|
|
28
|
+
{ key: 'down', label: 'Down' },
|
|
24
29
|
{ key: 'pending', label: 'Pending' },
|
|
25
30
|
]
|
|
31
|
+
const VERDICTS = [
|
|
32
|
+
{ key: 'all', label: 'All' },
|
|
33
|
+
{ key: 'Perfect', label: 'Perfect' },
|
|
34
|
+
{ key: 'Normal', label: 'Normal' },
|
|
35
|
+
{ key: 'Spiky', label: 'Spiky' },
|
|
36
|
+
{ key: 'Slow', label: 'Slow' },
|
|
37
|
+
{ key: 'Overloaded', label: 'Overloaded' },
|
|
38
|
+
{ key: 'Down', label: 'Down' },
|
|
39
|
+
{ key: 'Unstable', label: 'Unstable' },
|
|
40
|
+
{ key: 'Pending', label: 'Pending' },
|
|
41
|
+
]
|
|
42
|
+
const HEALTHS = [
|
|
43
|
+
{ key: 'all', label: 'All' },
|
|
44
|
+
{ key: 'up', label: 'Up' },
|
|
45
|
+
{ key: 'timeout', label: 'Timeout' },
|
|
46
|
+
{ key: 'down', label: 'Down' },
|
|
47
|
+
{ key: 'pending', label: 'Pending' },
|
|
48
|
+
{ key: 'noauth', label: 'No key' },
|
|
49
|
+
{ key: 'auth_error', label: 'Auth err' },
|
|
50
|
+
]
|
|
51
|
+
// 📖 TUI's E key cycle. The Web mirrors the same 3-state machine.
|
|
52
|
+
const VISIBILITY_MODES = [
|
|
53
|
+
{ key: 'normal', label: 'All models', hint: 'Show everything' },
|
|
54
|
+
{ key: 'configured', label: 'Configured only', hint: 'Hide models with no key or auth errors' },
|
|
55
|
+
{ key: 'usable', label: 'Usable only', hint: 'Only Health UP + good verdict' },
|
|
56
|
+
]
|
|
26
57
|
|
|
27
58
|
const PING_MODES = [
|
|
28
59
|
{ key: 'speed', label: '⚡ Speed', interval: '2s', color: '#00ff88' },
|
|
@@ -41,10 +72,36 @@ function formatCountdown(ms) {
|
|
|
41
72
|
return `${m}m${rem > 0 ? rem + 's' : ''}`
|
|
42
73
|
}
|
|
43
74
|
|
|
75
|
+
function ChipRow({ items, value, onChange, label }) {
|
|
76
|
+
return (
|
|
77
|
+
<div className={styles.group}>
|
|
78
|
+
{label && <label className={styles.filterLabel}>{label}</label>}
|
|
79
|
+
<div className={styles.chipRow}>
|
|
80
|
+
{items.map((item) => (
|
|
81
|
+
<button
|
|
82
|
+
key={item.key}
|
|
83
|
+
className={`${styles.chip} ${value === item.key ? styles.chipActive : ''}`}
|
|
84
|
+
onClick={() => onChange(item.key)}
|
|
85
|
+
title={item.hint || item.label}
|
|
86
|
+
>
|
|
87
|
+
{item.label}
|
|
88
|
+
</button>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
44
95
|
export default function FilterBar({
|
|
45
96
|
filterTier, setFilterTier,
|
|
46
97
|
filterStatus, setFilterStatus,
|
|
47
98
|
filterProvider, setFilterProvider,
|
|
99
|
+
filterVerdict, setFilterVerdict,
|
|
100
|
+
filterHealth, setFilterHealth,
|
|
101
|
+
visibilityMode, setVisibilityMode,
|
|
102
|
+
customTextFilter, setCustomTextFilter,
|
|
103
|
+
searchQuery,
|
|
104
|
+
onResetView,
|
|
48
105
|
providers,
|
|
49
106
|
pingMode, setPingMode,
|
|
50
107
|
nextPingAt,
|
|
@@ -55,7 +112,6 @@ export default function FilterBar({
|
|
|
55
112
|
}) {
|
|
56
113
|
const [countdown, setCountdown] = useState(null)
|
|
57
114
|
|
|
58
|
-
// Update countdown every second when nextPingAt is set
|
|
59
115
|
useEffect(() => {
|
|
60
116
|
if (nextPingAt == null) return
|
|
61
117
|
const tick = () => {
|
|
@@ -69,11 +125,28 @@ export default function FilterBar({
|
|
|
69
125
|
|
|
70
126
|
const countdownDisplay = countdown !== null ? formatCountdown(countdown) : null
|
|
71
127
|
|
|
72
|
-
//
|
|
128
|
+
// 📖 Custom text filter chip — sticks to the right of the search bar, with
|
|
129
|
+
// 📖 an "X" to clear (TUI's `X` key behavior).
|
|
130
|
+
const customFilterActive = Boolean(customTextFilter && customTextFilter.trim().length > 0)
|
|
131
|
+
|
|
73
132
|
const benchmarkPct = globalBenchmarkRunning && globalBenchmarkTotal > 0
|
|
74
133
|
? Math.round((globalBenchmarkCompleted / globalBenchmarkTotal) * 100)
|
|
75
134
|
: 0
|
|
76
135
|
|
|
136
|
+
// 📖 Count of active non-default filters to show on the Reset button.
|
|
137
|
+
const activeFilterCount = useMemo(() => {
|
|
138
|
+
let n = 0
|
|
139
|
+
if (filterTier !== 'all') n++
|
|
140
|
+
if (filterStatus !== 'all') n++
|
|
141
|
+
if (filterProvider !== 'all') n++
|
|
142
|
+
if (filterVerdict !== 'all') n++
|
|
143
|
+
if (filterHealth !== 'all') n++
|
|
144
|
+
if (visibilityMode !== 'normal') n++
|
|
145
|
+
if (customFilterActive) n++
|
|
146
|
+
if (searchQuery && searchQuery.trim().length > 0) n++
|
|
147
|
+
return n
|
|
148
|
+
}, [filterTier, filterStatus, filterProvider, filterVerdict, filterHealth, visibilityMode, customFilterActive, searchQuery])
|
|
149
|
+
|
|
77
150
|
return (
|
|
78
151
|
<section className={styles.filters}>
|
|
79
152
|
{/* ── Global benchmark progress bar (Ctrl+U) ── */}
|
|
@@ -90,54 +163,79 @@ export default function FilterBar({
|
|
|
90
163
|
<span className={styles.benchmarkPct}>{benchmarkPct}%</span>
|
|
91
164
|
</div>
|
|
92
165
|
)}
|
|
166
|
+
|
|
167
|
+
<ChipRow label="Tier" items={TIERS} value={filterTier} onChange={setFilterTier} />
|
|
168
|
+
<ChipRow label="Status" items={STATUSES} value={filterStatus} onChange={setFilterStatus} />
|
|
169
|
+
<ChipRow label="Verdict" items={VERDICTS} value={filterVerdict} onChange={setFilterVerdict} />
|
|
170
|
+
<ChipRow label="Health" items={HEALTHS} value={filterHealth} onChange={setFilterHealth} />
|
|
171
|
+
|
|
93
172
|
<div className={styles.group}>
|
|
94
|
-
<label className={styles.filterLabel}>
|
|
95
|
-
<
|
|
96
|
-
{
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
</button>
|
|
104
|
-
))}
|
|
105
|
-
</div>
|
|
106
|
-
</div>
|
|
107
|
-
<div className={styles.group}>
|
|
108
|
-
<label className={styles.filterLabel}>Status</label>
|
|
109
|
-
<div className={styles.tierRow}>
|
|
110
|
-
{STATUSES.map(s => (
|
|
111
|
-
<button
|
|
112
|
-
key={s.key}
|
|
113
|
-
className={`${styles.tierBtn} ${filterStatus === s.key ? styles.active : ''}`}
|
|
114
|
-
onClick={() => setFilterStatus(s.key)}
|
|
115
|
-
>
|
|
116
|
-
{s.label}
|
|
117
|
-
</button>
|
|
173
|
+
<label className={styles.filterLabel}>Visibility</label>
|
|
174
|
+
<select
|
|
175
|
+
className={styles.select}
|
|
176
|
+
value={visibilityMode}
|
|
177
|
+
onChange={(e) => setVisibilityMode(e.target.value)}
|
|
178
|
+
title={VISIBILITY_MODES.find((v) => v.key === visibilityMode)?.hint}
|
|
179
|
+
>
|
|
180
|
+
{VISIBILITY_MODES.map((v) => (
|
|
181
|
+
<option key={v.key} value={v.key}>{v.label}</option>
|
|
118
182
|
))}
|
|
119
|
-
</
|
|
183
|
+
</select>
|
|
120
184
|
</div>
|
|
185
|
+
|
|
121
186
|
<div className={styles.group}>
|
|
122
187
|
<label className={styles.filterLabel}>Provider</label>
|
|
123
188
|
<select
|
|
124
189
|
className={styles.providerSelect}
|
|
125
190
|
value={filterProvider}
|
|
126
|
-
onChange={e => setFilterProvider(e.target.value)}
|
|
191
|
+
onChange={(e) => setFilterProvider(e.target.value)}
|
|
127
192
|
>
|
|
128
193
|
<option value="all">All Providers</option>
|
|
129
|
-
{providers.map(p => (
|
|
194
|
+
{providers.map((p) => (
|
|
130
195
|
<option key={p.key} value={p.key}>{p.name} ({p.count})</option>
|
|
131
196
|
))}
|
|
132
197
|
</select>
|
|
133
198
|
</div>
|
|
199
|
+
|
|
134
200
|
<div className={styles.spacer} />
|
|
135
201
|
|
|
202
|
+
{/* ── Custom text filter chip (TUI's Ctrl+P "Apply text filter") ── */}
|
|
203
|
+
{customFilterActive && (
|
|
204
|
+
<div className={styles.group}>
|
|
205
|
+
<label className={styles.filterLabel}>Text</label>
|
|
206
|
+
<div className={styles.customFilterChip} title="Click X to clear (TUI: X key)">
|
|
207
|
+
<span className={styles.customFilterIcon}><IconFilter size={12} stroke={1.5} /></span>
|
|
208
|
+
<span className={styles.customFilterLabel}>{customTextFilter}</span>
|
|
209
|
+
<button
|
|
210
|
+
className={styles.customFilterClear}
|
|
211
|
+
onClick={() => setCustomTextFilter(null)}
|
|
212
|
+
title="Clear custom text filter (TUI: X)"
|
|
213
|
+
aria-label="Clear custom text filter"
|
|
214
|
+
>
|
|
215
|
+
<IconX size={12} stroke={2} />
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
{/* ── Reset view (TUI: N) — only visible when filters are active ── */}
|
|
222
|
+
{activeFilterCount > 0 && (
|
|
223
|
+
<button
|
|
224
|
+
className={styles.resetBtn}
|
|
225
|
+
onClick={onResetView}
|
|
226
|
+
title={`Reset ${activeFilterCount} active filter${activeFilterCount === 1 ? '' : 's'} (TUI: N)`}
|
|
227
|
+
>
|
|
228
|
+
<IconRefresh size={12} stroke={1.5} />
|
|
229
|
+
<span>Reset</span>
|
|
230
|
+
<span className={styles.resetBadge}>{activeFilterCount}</span>
|
|
231
|
+
</button>
|
|
232
|
+
)}
|
|
233
|
+
|
|
136
234
|
{/* ── Ping interval selector ── */}
|
|
137
235
|
<div className={styles.group}>
|
|
138
236
|
<label className={styles.filterLabel}>Ping</label>
|
|
139
237
|
<div className={styles.pingRow}>
|
|
140
|
-
{PING_MODES.map(m => (
|
|
238
|
+
{PING_MODES.map((m) => (
|
|
141
239
|
<button
|
|
142
240
|
key={m.key}
|
|
143
241
|
className={`${styles.pingBtn} ${pingMode === m.key ? styles.pingBtnActive : ''}`}
|
|
@@ -151,25 +249,20 @@ export default function FilterBar({
|
|
|
151
249
|
</div>
|
|
152
250
|
</div>
|
|
153
251
|
|
|
154
|
-
{/* ── Next ping countdown
|
|
252
|
+
{/* ── Next ping countdown (TUI parity: always show the delay) ──
|
|
253
|
+
📖 The TUI footer always renders `next : Xs` regardless of whether
|
|
254
|
+
📖 a ping is in flight — users care about the delay until the next
|
|
255
|
+
📖 cycle, not the "is pinging right now" boolean. We follow the same
|
|
256
|
+
📖 model: a single countdown line, with a small pulsing dot when
|
|
257
|
+
📖 `isPinging` is true so the LIVE state is still visible without
|
|
258
|
+
📖 resorting to the "Pinging…" text. */}
|
|
155
259
|
<div className={styles.group}>
|
|
156
|
-
{
|
|
157
|
-
<
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
) : countdownDisplay ? (
|
|
162
|
-
<div className={styles.nextPing} title="Next ping countdown">
|
|
163
|
-
<span className={styles.nextPingLabel}>Next</span>
|
|
164
|
-
<span className={styles.nextPingTime}>{countdownDisplay}</span>
|
|
165
|
-
</div>
|
|
166
|
-
) : (
|
|
167
|
-
<div className={styles.live}>
|
|
168
|
-
<span className={styles.liveDot} />
|
|
169
|
-
<span>LIVE</span>
|
|
170
|
-
</div>
|
|
171
|
-
)}
|
|
260
|
+
<div className={styles.nextPing} title="Next ping countdown">
|
|
261
|
+
<span className={styles.nextPingLabel}>next ping in</span>
|
|
262
|
+
<span className={styles.nextPingTime}>{countdownDisplay ?? '—'}</span>
|
|
263
|
+
{isPinging && <span className={styles.pingingDot} aria-hidden="true" />}
|
|
264
|
+
</div>
|
|
172
265
|
</div>
|
|
173
266
|
</section>
|
|
174
267
|
)
|
|
175
|
-
}
|
|
268
|
+
}
|