free-coding-models 0.5.0 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -6
- package/bin/free-coding-models.js +10 -0
- package/changelog/v0.5.1.md +24 -0
- package/changelog/v0.5.4.md +24 -0
- package/package.json +11 -3
- package/sources.js +2 -0
- package/src/core/router-daemon.js +166 -1
- package/src/core/utils.js +2 -0
- package/src/tui/cli-help.js +2 -0
- package/src/tui/key-handler.js +1 -1
- package/src/tui/render-table.js +1 -1
- package/web/README.md +8 -5
- package/web/assets/providers/aistudio/aistudio-text.svg +1 -0
- package/web/assets/providers/aistudio/aistudio.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-brand-color.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-brand.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-color.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-text.svg +1 -0
- package/web/assets/providers/cerebras/cerebras.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare-color.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare-text.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare.svg +1 -0
- package/web/assets/providers/gemini/gemini-color.svg +1 -0
- package/web/assets/providers/gemini/gemini-text.svg +1 -0
- package/web/assets/providers/gemini/gemini.svg +1 -0
- package/web/assets/providers/github/github-text.svg +1 -0
- package/web/assets/providers/github/github.svg +1 -0
- package/web/assets/providers/groq/groq-text.svg +1 -0
- package/web/assets/providers/groq/groq.svg +1 -0
- package/web/assets/providers/mistral/mistral-color.svg +1 -0
- package/web/assets/providers/mistral/mistral-text.svg +1 -0
- package/web/assets/providers/mistral/mistral.svg +1 -0
- package/web/assets/providers/nvidia/nvidia-color.svg +1 -0
- package/web/assets/providers/nvidia/nvidia-text.svg +1 -0
- package/web/assets/providers/nvidia/nvidia.svg +1 -0
- package/web/assets/providers/opencode/opencode-text.svg +1 -0
- package/web/assets/providers/opencode/opencode.svg +1 -0
- package/web/assets/providers/openrouter/openrouter (1).svg +1 -0
- package/web/assets/providers/openrouter/openrouter-text.svg +1 -0
- package/web/assets/providers/openrouter/openrouter.svg +1 -0
- package/web/assets/providers/ovhcloud/ovhcloud.svg +12 -0
- package/web/assets/providers/qwen/qwen-color.svg +1 -0
- package/web/assets/providers/qwen/qwen-text.svg +1 -0
- package/web/assets/providers/qwen/qwen.svg +1 -0
- package/web/assets/providers/sambanova/sambanova-color.svg +1 -0
- package/web/assets/providers/sambanova/sambanova-text.svg +1 -0
- package/web/assets/providers/sambanova/sambanova.svg +1 -0
- package/web/assets/providers/scalewaylogo/ScalewayLogo.svg +13 -0
- package/web/assets/providers/zai/zai-text.svg +1 -0
- package/web/assets/providers/zai/zai.svg +1 -0
- package/web/dist/assets/index-BoWmUveV.js +39 -0
- package/web/dist/assets/index-BrpHevg4.css +1 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/favicons/apple-touch-icon.png +0 -0
- package/web/dist/favicons/browserconfig.xml +12 -0
- package/web/dist/favicons/favicon-16x16.png +0 -0
- package/web/dist/favicons/favicon-192x192.png +0 -0
- package/web/dist/favicons/favicon-32x32.png +0 -0
- package/web/dist/favicons/favicon-48x48.png +0 -0
- package/web/dist/favicons/favicon-512x512.png +0 -0
- package/web/dist/favicons/favicon-96x96.png +0 -0
- package/web/dist/favicons/favicon.ico +0 -0
- package/web/dist/favicons/mstile-150x150.png +0 -0
- package/web/dist/favicons/mstile-310x150.png +0 -0
- package/web/dist/favicons/mstile-310x310.png +0 -0
- package/web/dist/favicons/mstile-512x512.png +0 -0
- package/web/dist/favicons/mstile-70x70.png +0 -0
- package/web/dist/favicons/site.webmanifest +25 -0
- package/web/dist/index.html +42 -6
- package/web/index.html +40 -4
- package/web/package.json +11 -0
- package/web/public/favicon.ico +0 -0
- package/web/public/favicons/apple-touch-icon.png +0 -0
- package/web/public/favicons/browserconfig.xml +12 -0
- package/web/public/favicons/favicon-16x16.png +0 -0
- package/web/public/favicons/favicon-192x192.png +0 -0
- package/web/public/favicons/favicon-32x32.png +0 -0
- package/web/public/favicons/favicon-48x48.png +0 -0
- package/web/public/favicons/favicon-512x512.png +0 -0
- package/web/public/favicons/favicon-96x96.png +0 -0
- package/web/public/favicons/favicon.ico +0 -0
- package/web/public/favicons/mstile-150x150.png +0 -0
- package/web/public/favicons/mstile-310x150.png +0 -0
- package/web/public/favicons/mstile-310x310.png +0 -0
- package/web/public/favicons/mstile-512x512.png +0 -0
- package/web/public/favicons/mstile-70x70.png +0 -0
- package/web/public/favicons/site.webmanifest +25 -0
- package/web/server.js +676 -211
- package/web/src/App.jsx +233 -71
- package/web/src/components/analytics/AnalyticsView.jsx +40 -6
- package/web/src/components/analytics/AnalyticsView.module.css +4 -0
- package/web/src/components/atoms/AILatencyCell.jsx +38 -0
- package/web/src/components/atoms/AILatencyCell.module.css +51 -0
- package/web/src/components/atoms/HealthCell.jsx +138 -0
- package/web/src/components/atoms/HealthCell.module.css +56 -0
- package/web/src/components/atoms/LastPingCell.jsx +35 -0
- package/web/src/components/atoms/LastPingCell.module.css +45 -0
- package/web/src/components/atoms/MoodCell.jsx +25 -0
- package/web/src/components/atoms/MoodCell.module.css +6 -0
- package/web/src/components/atoms/NoKeyIcon.jsx +65 -0
- package/web/src/components/atoms/NoKeyIcon.module.css +30 -0
- package/web/src/components/atoms/ProviderLogo.jsx +188 -0
- package/web/src/components/atoms/ProviderLogo.module.css +103 -0
- package/web/src/components/atoms/RankCell.jsx +9 -0
- package/web/src/components/atoms/RankCell.module.css +9 -0
- package/web/src/components/atoms/Sparkline.jsx +21 -2
- package/web/src/components/atoms/StabilityCell.module.css +5 -0
- package/web/src/components/atoms/StatusDot.module.css +7 -0
- package/web/src/components/atoms/TPSCell.jsx +36 -0
- package/web/src/components/atoms/TPSCell.module.css +44 -0
- package/web/src/components/atoms/TierBadge.module.css +11 -0
- package/web/src/components/atoms/Toast.module.css +6 -0
- package/web/src/components/atoms/VerdictBadge.jsx +30 -7
- package/web/src/components/atoms/VerdictBadge.module.css +36 -15
- package/web/src/components/dashboard/DetailPanel.jsx +63 -2
- package/web/src/components/dashboard/DetailPanel.module.css +130 -0
- package/web/src/components/dashboard/ExportModal.jsx +9 -4
- package/web/src/components/dashboard/FilterBar.jsx +228 -33
- package/web/src/components/dashboard/FilterBar.module.css +187 -22
- package/web/src/components/dashboard/ModelTable.jsx +500 -54
- package/web/src/components/dashboard/ModelTable.module.css +318 -31
- package/web/src/components/layout/Footer.jsx +1 -1
- package/web/src/components/layout/Header.jsx +171 -9
- package/web/src/components/layout/Header.module.css +223 -25
- package/web/src/components/palette/CommandPalette.jsx +146 -0
- package/web/src/components/palette/CommandPalette.module.css +121 -0
- package/web/src/components/settings/SettingsView.jsx +24 -6
- package/web/src/components/settings/SettingsView.module.css +0 -1
- package/web/src/global.css +118 -84
- package/web/src/hooks/useColumnSizing.js +211 -0
- package/web/src/hooks/useFavorites.js +150 -0
- package/web/src/hooks/useFilter.js +262 -37
- package/web/src/hooks/useSSE.js +33 -9
- package/web/src/hooks/useSocket.js +200 -0
- package/web/src/hooks/useTheme.js +72 -16
- package/web/src/hooks/useUrlState.js +93 -0
- package/web/vite.config.js +41 -0
- package/web/dist/assets/index-CGN-0_A0.css +0 -1
- package/web/dist/assets/index-CvMUM9Jr.js +0 -11
- package/web/src/components/dashboard/StatsBar.jsx +0 -40
- package/web/src/components/dashboard/StatsBar.module.css +0 -28
- package/web/src/components/layout/Sidebar.jsx +0 -42
- package/web/src/components/layout/Sidebar.module.css +0 -76
- package/web/src/components/map/MapView.jsx +0 -17
- package/web/src/components/map/MapView.module.css +0 -25
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* `onClose` callback, and `onToast` for feedback notifications.
|
|
6
6
|
* @functions ExportModal → renders the modal with three export option buttons
|
|
7
7
|
*/
|
|
8
|
+
import { IconUpload, IconBraces, IconFileSpreadsheet, IconClipboard } from '@tabler/icons-react'
|
|
8
9
|
import { downloadFile } from '../../utils/download.js'
|
|
9
10
|
import styles from './ExportModal.module.css'
|
|
10
11
|
|
|
@@ -45,27 +46,30 @@ export default function ExportModal({ models, onClose, onToast }) {
|
|
|
45
46
|
<div className={styles.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
|
|
46
47
|
<div className={styles.modal}>
|
|
47
48
|
<div className={styles.modalHeader}>
|
|
48
|
-
<h2 className={styles.modalTitle}
|
|
49
|
+
<h2 className={styles.modalTitle}>
|
|
50
|
+
<IconUpload size={20} stroke={1.5} style={{ marginRight: 8, verticalAlign: 'middle' }} />
|
|
51
|
+
Export Data
|
|
52
|
+
</h2>
|
|
49
53
|
<button className={styles.modalClose} onClick={onClose}>×</button>
|
|
50
54
|
</div>
|
|
51
55
|
<div className={styles.modalBody}>
|
|
52
56
|
<div className={styles.options}>
|
|
53
57
|
<button className={styles.option} onClick={handleJson}>
|
|
54
|
-
<span className={styles.optionIcon}
|
|
58
|
+
<span className={styles.optionIcon}><IconBraces size={20} stroke={1.5} /></span>
|
|
55
59
|
<div>
|
|
56
60
|
<span className={styles.optionLabel}>Export as JSON</span>
|
|
57
61
|
<span className={styles.optionDesc}>Full model data with all metrics</span>
|
|
58
62
|
</div>
|
|
59
63
|
</button>
|
|
60
64
|
<button className={styles.option} onClick={handleCsv}>
|
|
61
|
-
<span className={styles.optionIcon}
|
|
65
|
+
<span className={styles.optionIcon}><IconFileSpreadsheet size={20} stroke={1.5} /></span>
|
|
62
66
|
<div>
|
|
63
67
|
<span className={styles.optionLabel}>Export as CSV</span>
|
|
64
68
|
<span className={styles.optionDesc}>Spreadsheet-compatible format</span>
|
|
65
69
|
</div>
|
|
66
70
|
</button>
|
|
67
71
|
<button className={styles.option} onClick={handleClipboard}>
|
|
68
|
-
<span className={styles.optionIcon}
|
|
72
|
+
<span className={styles.optionIcon}><IconClipboard size={20} stroke={1.5} /></span>
|
|
69
73
|
<div>
|
|
70
74
|
<span className={styles.optionLabel}>Copy to Clipboard</span>
|
|
71
75
|
<span className={styles.optionDesc}>Copy model summary as text</span>
|
|
@@ -77,3 +81,4 @@ export default function ExportModal({ models, onClose, onToast }) {
|
|
|
77
81
|
</div>
|
|
78
82
|
)
|
|
79
83
|
}
|
|
84
|
+
|
|
@@ -1,71 +1,266 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file web/src/components/dashboard/FilterBar.jsx
|
|
3
|
-
* @description Filter controls for
|
|
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.
|
|
4
8
|
*/
|
|
9
|
+
import { useState, useEffect, useMemo } from 'react'
|
|
10
|
+
import { IconRefresh, IconX, IconFilter, IconCircleCheck, IconAlertTriangle, IconActivity, IconEye, IconStar } from '@tabler/icons-react'
|
|
5
11
|
import styles from './FilterBar.module.css'
|
|
6
12
|
|
|
7
|
-
|
|
13
|
+
// 📖 Chip sets match the TUI cycles 1:1 (see useFilter.js). Keep these in sync.
|
|
14
|
+
const TIERS = [
|
|
15
|
+
{ key: 'all', label: 'All' },
|
|
16
|
+
{ key: 'S+', label: 'S+' },
|
|
17
|
+
{ key: 'S', label: 'S' },
|
|
18
|
+
{ key: 'A+', label: 'A+' },
|
|
19
|
+
{ key: 'A', label: 'A' },
|
|
20
|
+
{ key: 'A-', label: 'A-' },
|
|
21
|
+
{ key: 'B+', label: 'B+' },
|
|
22
|
+
{ key: 'B', label: 'B' },
|
|
23
|
+
{ key: 'C', label: 'C' },
|
|
24
|
+
]
|
|
8
25
|
const STATUSES = [
|
|
9
26
|
{ key: 'all', label: 'All' },
|
|
10
|
-
{ key: 'up', label: '
|
|
11
|
-
{ key: 'down', label: '
|
|
27
|
+
{ key: 'up', label: 'Up' },
|
|
28
|
+
{ key: 'down', label: 'Down' },
|
|
29
|
+
{ key: 'pending', label: 'Pending' },
|
|
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' },
|
|
12
47
|
{ key: 'pending', label: 'Pending' },
|
|
48
|
+
{ key: 'noauth', label: 'No key' },
|
|
49
|
+
{ key: 'auth_error', label: 'Auth err' },
|
|
13
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
|
+
]
|
|
57
|
+
|
|
58
|
+
const PING_MODES = [
|
|
59
|
+
{ key: 'speed', label: '⚡ Speed', interval: '2s', color: '#00ff88' },
|
|
60
|
+
{ key: 'normal', label: '● Normal', interval: '10s', color: '#ffaa00' },
|
|
61
|
+
{ key: 'slow', label: '🐢 Slow', interval: '30s', color: '#ff6644' },
|
|
62
|
+
{ key: 'forced', label: '🔥 Forced', interval: '4s', color: '#ff4466' },
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
function formatCountdown(ms) {
|
|
66
|
+
if (ms == null) return null
|
|
67
|
+
const s = Math.max(0, Math.ceil(ms / 1000))
|
|
68
|
+
if (s === 1) return '1s'
|
|
69
|
+
if (s < 60) return `${s}s`
|
|
70
|
+
const m = Math.floor(s / 60)
|
|
71
|
+
const rem = s % 60
|
|
72
|
+
return `${m}m${rem > 0 ? rem + 's' : ''}`
|
|
73
|
+
}
|
|
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
|
+
}
|
|
14
94
|
|
|
15
95
|
export default function FilterBar({
|
|
16
96
|
filterTier, setFilterTier,
|
|
17
97
|
filterStatus, setFilterStatus,
|
|
18
98
|
filterProvider, setFilterProvider,
|
|
99
|
+
filterVerdict, setFilterVerdict,
|
|
100
|
+
filterHealth, setFilterHealth,
|
|
101
|
+
visibilityMode, setVisibilityMode,
|
|
102
|
+
customTextFilter, setCustomTextFilter,
|
|
103
|
+
searchQuery,
|
|
104
|
+
onResetView,
|
|
19
105
|
providers,
|
|
106
|
+
pingMode, setPingMode,
|
|
107
|
+
nextPingAt,
|
|
108
|
+
isPinging,
|
|
109
|
+
globalBenchmarkRunning,
|
|
110
|
+
globalBenchmarkTotal,
|
|
111
|
+
globalBenchmarkCompleted,
|
|
20
112
|
}) {
|
|
113
|
+
const [countdown, setCountdown] = useState(null)
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (nextPingAt == null) return
|
|
117
|
+
const tick = () => {
|
|
118
|
+
const rem = nextPingAt - Date.now()
|
|
119
|
+
setCountdown(rem > 0 ? rem : 0)
|
|
120
|
+
}
|
|
121
|
+
tick()
|
|
122
|
+
const id = setInterval(tick, 1000)
|
|
123
|
+
return () => clearInterval(id)
|
|
124
|
+
}, [nextPingAt])
|
|
125
|
+
|
|
126
|
+
const countdownDisplay = countdown !== null ? formatCountdown(countdown) : null
|
|
127
|
+
|
|
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
|
+
|
|
132
|
+
const benchmarkPct = globalBenchmarkRunning && globalBenchmarkTotal > 0
|
|
133
|
+
? Math.round((globalBenchmarkCompleted / globalBenchmarkTotal) * 100)
|
|
134
|
+
: 0
|
|
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
|
+
|
|
21
150
|
return (
|
|
22
151
|
<section className={styles.filters}>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
<div className={styles.
|
|
26
|
-
{
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
152
|
+
{/* ── Global benchmark progress bar (Ctrl+U) ── */}
|
|
153
|
+
{globalBenchmarkRunning && (
|
|
154
|
+
<div className={styles.benchmarkBar}>
|
|
155
|
+
<div className={styles.benchmarkLabel}>
|
|
156
|
+
<span className={styles.benchmarkSpinner} />
|
|
157
|
+
<span>AI Speed Test</span>
|
|
158
|
+
<span className={styles.benchmarkCount}>{globalBenchmarkCompleted}/{globalBenchmarkTotal}</span>
|
|
159
|
+
</div>
|
|
160
|
+
<div className={styles.benchmarkTrack}>
|
|
161
|
+
<div className={styles.benchmarkFill} style={{ width: `${benchmarkPct}%` }} />
|
|
162
|
+
</div>
|
|
163
|
+
<span className={styles.benchmarkPct}>{benchmarkPct}%</span>
|
|
35
164
|
</div>
|
|
36
|
-
|
|
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
|
+
|
|
37
172
|
<div className={styles.group}>
|
|
38
|
-
<label className={styles.filterLabel}>
|
|
39
|
-
<
|
|
40
|
-
{
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
</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>
|
|
48
182
|
))}
|
|
49
|
-
</
|
|
183
|
+
</select>
|
|
50
184
|
</div>
|
|
185
|
+
|
|
51
186
|
<div className={styles.group}>
|
|
52
187
|
<label className={styles.filterLabel}>Provider</label>
|
|
53
188
|
<select
|
|
54
189
|
className={styles.providerSelect}
|
|
55
190
|
value={filterProvider}
|
|
56
|
-
onChange={e => setFilterProvider(e.target.value)}
|
|
191
|
+
onChange={(e) => setFilterProvider(e.target.value)}
|
|
57
192
|
>
|
|
58
193
|
<option value="all">All Providers</option>
|
|
59
|
-
{providers.map(p => (
|
|
194
|
+
{providers.map((p) => (
|
|
60
195
|
<option key={p.key} value={p.key}>{p.name} ({p.count})</option>
|
|
61
196
|
))}
|
|
62
197
|
</select>
|
|
63
198
|
</div>
|
|
199
|
+
|
|
64
200
|
<div className={styles.spacer} />
|
|
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
|
+
|
|
234
|
+
{/* ── Ping interval selector ── */}
|
|
235
|
+
<div className={styles.group}>
|
|
236
|
+
<label className={styles.filterLabel}>Ping</label>
|
|
237
|
+
<div className={styles.pingRow}>
|
|
238
|
+
{PING_MODES.map((m) => (
|
|
239
|
+
<button
|
|
240
|
+
key={m.key}
|
|
241
|
+
className={`${styles.pingBtn} ${pingMode === m.key ? styles.pingBtnActive : ''}`}
|
|
242
|
+
style={pingMode === m.key ? { '--ping-active-color': m.color } : {}}
|
|
243
|
+
onClick={() => setPingMode(m.key)}
|
|
244
|
+
title={`${m.interval} interval`}
|
|
245
|
+
>
|
|
246
|
+
{m.label}
|
|
247
|
+
</button>
|
|
248
|
+
))}
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
|
|
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. */}
|
|
65
259
|
<div className={styles.group}>
|
|
66
|
-
<div className={styles.
|
|
67
|
-
<span className={styles.
|
|
68
|
-
<span>
|
|
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" />}
|
|
69
264
|
</div>
|
|
70
265
|
</div>
|
|
71
266
|
</section>
|
|
@@ -1,43 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/dashboard/FilterBar.module.css
|
|
3
|
+
* @description Filter bar styles — M1 parity chips (tier, status, verdict, health,
|
|
4
|
+
* visibility, provider, custom text filter, reset, ping mode).
|
|
5
|
+
* 📖 Sticky below the header (`top: 60px` = header height) so the filter chips
|
|
6
|
+
* 📖 stay visible while the user scrolls a long table.
|
|
7
|
+
*/
|
|
8
|
+
|
|
1
9
|
.filters {
|
|
2
|
-
position:
|
|
3
|
-
display: flex; align-items: center; gap:
|
|
4
|
-
padding:
|
|
10
|
+
position: sticky; top: 60px; z-index: 99;
|
|
11
|
+
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
|
|
12
|
+
padding: 10px 20px;
|
|
13
|
+
background: var(--color-bg-card);
|
|
14
|
+
border-bottom: 1px solid var(--color-border);
|
|
15
|
+
backdrop-filter: blur(20px) saturate(1.5);
|
|
16
|
+
-webkit-backdrop-filter: blur(20px) saturate(1.5);
|
|
5
17
|
}
|
|
6
|
-
|
|
18
|
+
|
|
19
|
+
.group { display: flex; align-items: center; gap: 6px; }
|
|
20
|
+
|
|
7
21
|
.filterLabel {
|
|
8
|
-
font-size:
|
|
22
|
+
font-size: 10px; font-weight: 700; color: var(--color-text-muted);
|
|
9
23
|
text-transform: uppercase; letter-spacing: 0.5px;
|
|
24
|
+
white-space: nowrap;
|
|
10
25
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
26
|
+
|
|
27
|
+
/* ── Chip rows (TUI cycle behavior) ── */
|
|
28
|
+
.chipRow { display: flex; gap: 3px; }
|
|
29
|
+
.chip {
|
|
30
|
+
font-size: 11px; font-weight: 600; font-family: var(--font-mono);
|
|
31
|
+
padding: 4px 8px; border-radius: 5px;
|
|
15
32
|
border: 1px solid var(--color-border); background: var(--color-surface);
|
|
16
33
|
color: var(--color-text-muted); cursor: pointer; transition: all 150ms;
|
|
34
|
+
white-space: nowrap;
|
|
17
35
|
}
|
|
18
|
-
.
|
|
19
|
-
.
|
|
20
|
-
background: var(--color-accent) !important; color:
|
|
36
|
+
.chip:hover { background: var(--color-bg-hover); color: var(--color-text); }
|
|
37
|
+
.chipActive {
|
|
38
|
+
background: var(--color-accent) !important; color: var(--color-bg) !important;
|
|
21
39
|
border-color: var(--color-accent) !important; font-weight: 700;
|
|
22
40
|
}
|
|
23
|
-
|
|
41
|
+
|
|
42
|
+
/* ── Select inputs (provider, visibility) ── */
|
|
43
|
+
.select, .providerSelect {
|
|
24
44
|
background: var(--color-surface); color: var(--color-text);
|
|
25
|
-
border: 1px solid var(--color-border); border-radius:
|
|
26
|
-
padding:
|
|
45
|
+
border: 1px solid var(--color-border); border-radius: 5px;
|
|
46
|
+
padding: 4px 8px; font-size: 11px; font-family: var(--font-sans);
|
|
27
47
|
cursor: pointer; outline: none;
|
|
48
|
+
min-width: 130px;
|
|
28
49
|
}
|
|
29
|
-
.providerSelect:focus { border-color: var(--color-accent); }
|
|
50
|
+
.select:focus, .providerSelect:focus { border-color: var(--color-accent); }
|
|
51
|
+
|
|
52
|
+
/* ── Spacer + reset ── */
|
|
30
53
|
.spacer { flex: 1; }
|
|
31
|
-
|
|
54
|
+
|
|
55
|
+
.resetBtn {
|
|
56
|
+
display: inline-flex; align-items: center; gap: 5px;
|
|
57
|
+
font-size: 11px; font-weight: 700; font-family: var(--font-mono);
|
|
58
|
+
padding: 4px 9px; border-radius: 5px;
|
|
59
|
+
border: 1px solid var(--color-border-hover);
|
|
60
|
+
background: var(--color-accent-dim);
|
|
61
|
+
color: var(--color-text);
|
|
62
|
+
cursor: pointer; transition: all 150ms;
|
|
63
|
+
}
|
|
64
|
+
.resetBtn:hover {
|
|
65
|
+
background: var(--color-accent);
|
|
66
|
+
color: var(--color-bg);
|
|
67
|
+
}
|
|
68
|
+
.resetBadge {
|
|
69
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
70
|
+
min-width: 16px; height: 16px;
|
|
71
|
+
font-size: 9px; font-weight: 800;
|
|
72
|
+
background: var(--color-bg);
|
|
73
|
+
color: var(--color-accent);
|
|
74
|
+
border-radius: 999px;
|
|
75
|
+
padding: 0 4px;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ── Custom text filter chip (TUI Ctrl+P "Apply text filter") ── */
|
|
79
|
+
.customFilterChip {
|
|
80
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
81
|
+
font-size: 11px; font-weight: 600; font-family: var(--font-mono);
|
|
82
|
+
padding: 3px 4px 3px 8px;
|
|
83
|
+
border: 1px solid var(--color-accent);
|
|
84
|
+
background: var(--color-accent-dim);
|
|
85
|
+
color: var(--color-accent);
|
|
86
|
+
border-radius: 5px;
|
|
87
|
+
max-width: 240px;
|
|
88
|
+
}
|
|
89
|
+
.customFilterIcon { display: inline-flex; flex-shrink: 0; }
|
|
90
|
+
.customFilterLabel {
|
|
91
|
+
overflow: hidden;
|
|
92
|
+
text-overflow: ellipsis;
|
|
93
|
+
white-space: nowrap;
|
|
94
|
+
}
|
|
95
|
+
.customFilterClear {
|
|
96
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
97
|
+
width: 18px; height: 18px;
|
|
98
|
+
background: transparent;
|
|
99
|
+
border: none;
|
|
100
|
+
color: var(--color-accent);
|
|
101
|
+
border-radius: 3px;
|
|
102
|
+
cursor: pointer;
|
|
103
|
+
flex-shrink: 0;
|
|
104
|
+
}
|
|
105
|
+
.customFilterClear:hover { background: var(--color-accent); color: var(--color-bg); }
|
|
106
|
+
|
|
107
|
+
/* ── Ping interval selector ── */
|
|
108
|
+
.pingRow { display: flex; gap: 3px; }
|
|
109
|
+
.pingBtn {
|
|
110
|
+
font-size: 11px; font-weight: 600; font-family: var(--font-mono);
|
|
111
|
+
padding: 4px 8px; border-radius: 5px;
|
|
112
|
+
border: 1px solid var(--color-border); background: var(--color-surface);
|
|
113
|
+
color: var(--color-text-muted); cursor: pointer; transition: all 150ms;
|
|
114
|
+
white-space: nowrap;
|
|
115
|
+
}
|
|
116
|
+
.pingBtn:hover { background: var(--color-bg-hover); color: var(--color-text); border-color: var(--color-text-muted); }
|
|
117
|
+
.pingBtnActive {
|
|
118
|
+
background: var(--color-bg-hover) !important;
|
|
119
|
+
color: var(--ping-active-color, var(--color-accent)) !important;
|
|
120
|
+
border-color: var(--ping-active-color, var(--color-accent)) !important;
|
|
121
|
+
font-weight: 700;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* ── Next ping countdown (TUI parity) ── */
|
|
125
|
+
.nextPing {
|
|
32
126
|
display: flex; align-items: center; gap: 6px;
|
|
33
|
-
font-size: 11px; font-
|
|
34
|
-
|
|
127
|
+
font-size: 11px; font-family: var(--font-mono);
|
|
128
|
+
padding: 4px 8px;
|
|
129
|
+
border: 1px solid var(--color-border);
|
|
130
|
+
border-radius: 5px;
|
|
131
|
+
background: var(--color-surface);
|
|
35
132
|
}
|
|
36
|
-
.
|
|
37
|
-
|
|
38
|
-
|
|
133
|
+
.nextPingLabel {
|
|
134
|
+
font-size: 10px; font-weight: 500; color: var(--color-text-muted);
|
|
135
|
+
text-transform: uppercase; letter-spacing: 0.5px;
|
|
39
136
|
}
|
|
137
|
+
.nextPingTime {
|
|
138
|
+
font-size: 13px; font-weight: 700; color: var(--color-text);
|
|
139
|
+
min-width: 32px;
|
|
140
|
+
text-align: right;
|
|
141
|
+
font-variant-numeric: tabular-nums;
|
|
142
|
+
}
|
|
143
|
+
.pingingDot {
|
|
144
|
+
width: 7px; height: 7px; border-radius: 50%;
|
|
145
|
+
background: #ff4466; animation: pulseDot 0.6s ease-in-out infinite;
|
|
146
|
+
flex-shrink: 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
40
149
|
@keyframes pulseDot {
|
|
41
150
|
0%, 100% { box-shadow: 0 0 0 0 var(--color-accent-glow); }
|
|
42
151
|
50% { box-shadow: 0 0 0 6px transparent; }
|
|
43
152
|
}
|
|
153
|
+
|
|
154
|
+
.live {
|
|
155
|
+
display: flex; align-items: center; gap: 5px;
|
|
156
|
+
font-size: 10px; font-weight: 700; color: var(--color-accent);
|
|
157
|
+
text-transform: uppercase; letter-spacing: 1px;
|
|
158
|
+
}
|
|
159
|
+
.liveDot {
|
|
160
|
+
width: 7px; height: 7px; border-radius: 50%;
|
|
161
|
+
background: var(--color-accent); animation: pulseDot 2s ease-in-out infinite;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* ── Global benchmark progress bar (Ctrl+U) ── */
|
|
165
|
+
.benchmarkBar {
|
|
166
|
+
display: flex; align-items: center; gap: 10px;
|
|
167
|
+
background: rgba(180, 0, 255, 0.12);
|
|
168
|
+
border: 1px solid rgba(180, 0, 255, 0.4);
|
|
169
|
+
border-radius: 6px;
|
|
170
|
+
padding: 6px 12px;
|
|
171
|
+
flex-shrink: 0;
|
|
172
|
+
width: 100%;
|
|
173
|
+
margin-bottom: 4px;
|
|
174
|
+
}
|
|
175
|
+
.benchmarkLabel {
|
|
176
|
+
display: flex; align-items: center; gap: 5px;
|
|
177
|
+
font-size: 11px; font-weight: 700;
|
|
178
|
+
color: #b400ff; font-family: var(--font-mono);
|
|
179
|
+
white-space: nowrap;
|
|
180
|
+
}
|
|
181
|
+
.benchmarkSpinner {
|
|
182
|
+
display: inline-block;
|
|
183
|
+
animation: spinSp 0.8s linear infinite;
|
|
184
|
+
font-size: 12px; line-height: 1;
|
|
185
|
+
}
|
|
186
|
+
.benchmarkCount { font-size: 10px; opacity: 0.8; }
|
|
187
|
+
.benchmarkTrack {
|
|
188
|
+
width: 120px; height: 5px;
|
|
189
|
+
background: rgba(255,255,255,0.1);
|
|
190
|
+
border-radius: 3px; overflow: hidden;
|
|
191
|
+
flex: 1;
|
|
192
|
+
}
|
|
193
|
+
.benchmarkFill {
|
|
194
|
+
height: 100%;
|
|
195
|
+
background: linear-gradient(90deg, #b400ff, #ff00ff);
|
|
196
|
+
border-radius: 3px;
|
|
197
|
+
transition: width 0.5s ease;
|
|
198
|
+
}
|
|
199
|
+
.benchmarkPct {
|
|
200
|
+
font-size: 11px; font-weight: 700;
|
|
201
|
+
color: #b400ff; font-family: var(--font-mono);
|
|
202
|
+
min-width: 32px; text-align: right;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
@keyframes spinSp {
|
|
206
|
+
from { transform: rotate(0deg); }
|
|
207
|
+
to { transform: rotate(360deg); }
|
|
208
|
+
}
|