free-coding-models 0.5.0 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/README.md +40 -6
  2. package/bin/free-coding-models.js +10 -0
  3. package/changelog/v0.5.1.md +24 -0
  4. package/changelog/v0.5.4.md +24 -0
  5. package/package.json +11 -3
  6. package/sources.js +2 -0
  7. package/src/core/router-daemon.js +166 -1
  8. package/src/core/utils.js +2 -0
  9. package/src/tui/cli-help.js +2 -0
  10. package/src/tui/key-handler.js +1 -1
  11. package/src/tui/render-table.js +1 -1
  12. package/web/README.md +8 -5
  13. package/web/assets/providers/aistudio/aistudio-text.svg +1 -0
  14. package/web/assets/providers/aistudio/aistudio.svg +1 -0
  15. package/web/assets/providers/cerebras/cerebras-brand-color.svg +1 -0
  16. package/web/assets/providers/cerebras/cerebras-brand.svg +1 -0
  17. package/web/assets/providers/cerebras/cerebras-color.svg +1 -0
  18. package/web/assets/providers/cerebras/cerebras-text.svg +1 -0
  19. package/web/assets/providers/cerebras/cerebras.svg +1 -0
  20. package/web/assets/providers/cloudflare/cloudflare-color.svg +1 -0
  21. package/web/assets/providers/cloudflare/cloudflare-text.svg +1 -0
  22. package/web/assets/providers/cloudflare/cloudflare.svg +1 -0
  23. package/web/assets/providers/gemini/gemini-color.svg +1 -0
  24. package/web/assets/providers/gemini/gemini-text.svg +1 -0
  25. package/web/assets/providers/gemini/gemini.svg +1 -0
  26. package/web/assets/providers/github/github-text.svg +1 -0
  27. package/web/assets/providers/github/github.svg +1 -0
  28. package/web/assets/providers/groq/groq-text.svg +1 -0
  29. package/web/assets/providers/groq/groq.svg +1 -0
  30. package/web/assets/providers/mistral/mistral-color.svg +1 -0
  31. package/web/assets/providers/mistral/mistral-text.svg +1 -0
  32. package/web/assets/providers/mistral/mistral.svg +1 -0
  33. package/web/assets/providers/nvidia/nvidia-color.svg +1 -0
  34. package/web/assets/providers/nvidia/nvidia-text.svg +1 -0
  35. package/web/assets/providers/nvidia/nvidia.svg +1 -0
  36. package/web/assets/providers/opencode/opencode-text.svg +1 -0
  37. package/web/assets/providers/opencode/opencode.svg +1 -0
  38. package/web/assets/providers/openrouter/openrouter (1).svg +1 -0
  39. package/web/assets/providers/openrouter/openrouter-text.svg +1 -0
  40. package/web/assets/providers/openrouter/openrouter.svg +1 -0
  41. package/web/assets/providers/ovhcloud/ovhcloud.svg +12 -0
  42. package/web/assets/providers/qwen/qwen-color.svg +1 -0
  43. package/web/assets/providers/qwen/qwen-text.svg +1 -0
  44. package/web/assets/providers/qwen/qwen.svg +1 -0
  45. package/web/assets/providers/sambanova/sambanova-color.svg +1 -0
  46. package/web/assets/providers/sambanova/sambanova-text.svg +1 -0
  47. package/web/assets/providers/sambanova/sambanova.svg +1 -0
  48. package/web/assets/providers/scalewaylogo/ScalewayLogo.svg +13 -0
  49. package/web/assets/providers/zai/zai-text.svg +1 -0
  50. package/web/assets/providers/zai/zai.svg +1 -0
  51. package/web/dist/assets/index-BoWmUveV.js +39 -0
  52. package/web/dist/assets/index-BrpHevg4.css +1 -0
  53. package/web/dist/favicon.ico +0 -0
  54. package/web/dist/favicons/apple-touch-icon.png +0 -0
  55. package/web/dist/favicons/browserconfig.xml +12 -0
  56. package/web/dist/favicons/favicon-16x16.png +0 -0
  57. package/web/dist/favicons/favicon-192x192.png +0 -0
  58. package/web/dist/favicons/favicon-32x32.png +0 -0
  59. package/web/dist/favicons/favicon-48x48.png +0 -0
  60. package/web/dist/favicons/favicon-512x512.png +0 -0
  61. package/web/dist/favicons/favicon-96x96.png +0 -0
  62. package/web/dist/favicons/favicon.ico +0 -0
  63. package/web/dist/favicons/mstile-150x150.png +0 -0
  64. package/web/dist/favicons/mstile-310x150.png +0 -0
  65. package/web/dist/favicons/mstile-310x310.png +0 -0
  66. package/web/dist/favicons/mstile-512x512.png +0 -0
  67. package/web/dist/favicons/mstile-70x70.png +0 -0
  68. package/web/dist/favicons/site.webmanifest +25 -0
  69. package/web/dist/index.html +42 -6
  70. package/web/index.html +40 -4
  71. package/web/package.json +11 -0
  72. package/web/public/favicon.ico +0 -0
  73. package/web/public/favicons/apple-touch-icon.png +0 -0
  74. package/web/public/favicons/browserconfig.xml +12 -0
  75. package/web/public/favicons/favicon-16x16.png +0 -0
  76. package/web/public/favicons/favicon-192x192.png +0 -0
  77. package/web/public/favicons/favicon-32x32.png +0 -0
  78. package/web/public/favicons/favicon-48x48.png +0 -0
  79. package/web/public/favicons/favicon-512x512.png +0 -0
  80. package/web/public/favicons/favicon-96x96.png +0 -0
  81. package/web/public/favicons/favicon.ico +0 -0
  82. package/web/public/favicons/mstile-150x150.png +0 -0
  83. package/web/public/favicons/mstile-310x150.png +0 -0
  84. package/web/public/favicons/mstile-310x310.png +0 -0
  85. package/web/public/favicons/mstile-512x512.png +0 -0
  86. package/web/public/favicons/mstile-70x70.png +0 -0
  87. package/web/public/favicons/site.webmanifest +25 -0
  88. package/web/server.js +676 -211
  89. package/web/src/App.jsx +233 -71
  90. package/web/src/components/analytics/AnalyticsView.jsx +40 -6
  91. package/web/src/components/analytics/AnalyticsView.module.css +4 -0
  92. package/web/src/components/atoms/AILatencyCell.jsx +38 -0
  93. package/web/src/components/atoms/AILatencyCell.module.css +51 -0
  94. package/web/src/components/atoms/HealthCell.jsx +138 -0
  95. package/web/src/components/atoms/HealthCell.module.css +56 -0
  96. package/web/src/components/atoms/LastPingCell.jsx +35 -0
  97. package/web/src/components/atoms/LastPingCell.module.css +45 -0
  98. package/web/src/components/atoms/MoodCell.jsx +25 -0
  99. package/web/src/components/atoms/MoodCell.module.css +6 -0
  100. package/web/src/components/atoms/NoKeyIcon.jsx +65 -0
  101. package/web/src/components/atoms/NoKeyIcon.module.css +30 -0
  102. package/web/src/components/atoms/ProviderLogo.jsx +188 -0
  103. package/web/src/components/atoms/ProviderLogo.module.css +103 -0
  104. package/web/src/components/atoms/RankCell.jsx +9 -0
  105. package/web/src/components/atoms/RankCell.module.css +9 -0
  106. package/web/src/components/atoms/Sparkline.jsx +21 -2
  107. package/web/src/components/atoms/StabilityCell.module.css +5 -0
  108. package/web/src/components/atoms/StatusDot.module.css +7 -0
  109. package/web/src/components/atoms/TPSCell.jsx +36 -0
  110. package/web/src/components/atoms/TPSCell.module.css +44 -0
  111. package/web/src/components/atoms/TierBadge.module.css +11 -0
  112. package/web/src/components/atoms/Toast.module.css +6 -0
  113. package/web/src/components/atoms/VerdictBadge.jsx +30 -7
  114. package/web/src/components/atoms/VerdictBadge.module.css +36 -15
  115. package/web/src/components/dashboard/DetailPanel.jsx +63 -2
  116. package/web/src/components/dashboard/DetailPanel.module.css +130 -0
  117. package/web/src/components/dashboard/ExportModal.jsx +9 -4
  118. package/web/src/components/dashboard/FilterBar.jsx +228 -33
  119. package/web/src/components/dashboard/FilterBar.module.css +187 -22
  120. package/web/src/components/dashboard/ModelTable.jsx +500 -54
  121. package/web/src/components/dashboard/ModelTable.module.css +318 -31
  122. package/web/src/components/layout/Footer.jsx +1 -1
  123. package/web/src/components/layout/Header.jsx +171 -9
  124. package/web/src/components/layout/Header.module.css +223 -25
  125. package/web/src/components/palette/CommandPalette.jsx +146 -0
  126. package/web/src/components/palette/CommandPalette.module.css +121 -0
  127. package/web/src/components/settings/SettingsView.jsx +24 -6
  128. package/web/src/components/settings/SettingsView.module.css +0 -1
  129. package/web/src/global.css +118 -84
  130. package/web/src/hooks/useColumnSizing.js +211 -0
  131. package/web/src/hooks/useFavorites.js +150 -0
  132. package/web/src/hooks/useFilter.js +262 -37
  133. package/web/src/hooks/useSSE.js +33 -9
  134. package/web/src/hooks/useSocket.js +200 -0
  135. package/web/src/hooks/useTheme.js +72 -16
  136. package/web/src/hooks/useUrlState.js +93 -0
  137. package/web/vite.config.js +41 -0
  138. package/web/dist/assets/index-CGN-0_A0.css +0 -1
  139. package/web/dist/assets/index-CvMUM9Jr.js +0 -11
  140. package/web/src/components/dashboard/StatsBar.jsx +0 -40
  141. package/web/src/components/dashboard/StatsBar.module.css +0 -28
  142. package/web/src/components/layout/Sidebar.jsx +0 -42
  143. package/web/src/components/layout/Sidebar.module.css +0 -76
  144. package/web/src/components/map/MapView.jsx +0 -17
  145. package/web/src/components/map/MapView.module.css +0 -25
@@ -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}>📤 Export Data</h2>
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}>&times;</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}>{'{ }'}</span>
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}>📊</span>
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}>📋</span>
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 tier, status, provider, and search + live indicator.
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
- const TIERS = ['All', 'S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
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: 'Online' },
11
- { key: 'down', label: 'Offline' },
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
- <div className={styles.group}>
24
- <label className={styles.filterLabel}>Tier</label>
25
- <div className={styles.tierRow}>
26
- {TIERS.map(t => (
27
- <button
28
- key={t}
29
- className={`${styles.tierBtn} ${filterTier === t ? styles.active : ''}`}
30
- onClick={() => setFilterTier(t)}
31
- >
32
- {t}
33
- </button>
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
- </div>
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}>Status</label>
39
- <div className={styles.tierRow}>
40
- {STATUSES.map(s => (
41
- <button
42
- key={s.key}
43
- className={`${styles.tierBtn} ${filterStatus === s.key ? styles.active : ''}`}
44
- onClick={() => setFilterStatus(s.key)}
45
- >
46
- {s.label}
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
- </div>
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.live}>
67
- <span className={styles.liveDot} />
68
- <span>LIVE</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: relative; z-index: 1;
3
- display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
4
- padding: 16px 24px;
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
- .group { display: flex; align-items: center; gap: 8px; }
18
+
19
+ .group { display: flex; align-items: center; gap: 6px; }
20
+
7
21
  .filterLabel {
8
- font-size: 11px; font-weight: 600; color: var(--color-text-muted);
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
- .tierRow { display: flex; gap: 4px; }
12
- .tierBtn {
13
- font-size: 12px; font-weight: 600; font-family: var(--font-mono);
14
- padding: 4px 10px; border-radius: 6px;
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
- .tierBtn:hover { background: var(--color-bg-hover); color: var(--color-text); }
19
- .active {
20
- background: var(--color-accent) !important; color: #000 !important;
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
- .providerSelect {
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: 6px;
26
- padding: 5px 10px; font-size: 12px; font-family: var(--font-sans);
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
- .live {
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-weight: 700; color: var(--color-accent);
34
- text-transform: uppercase; letter-spacing: 1px;
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
- .liveDot {
37
- width: 8px; height: 8px; border-radius: 50%;
38
- background: var(--color-accent); animation: pulseDot 2s ease-in-out infinite;
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
+ }