free-coding-models 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +9 -1
  2. package/bin/free-coding-models.js +10 -0
  3. package/changelog/v0.5.1.md +24 -0
  4. package/package.json +7 -2
  5. package/src/core/router-daemon.js +166 -1
  6. package/src/core/utils.js +2 -0
  7. package/src/tui/cli-help.js +2 -0
  8. package/src/tui/render-table.js +1 -1
  9. package/web/README.md +8 -5
  10. package/web/dist/assets/index-ByGf4Kq-.js +14 -0
  11. package/web/dist/assets/index-Ds7wmHBv.css +1 -0
  12. package/web/dist/index.html +3 -6
  13. package/web/index.html +1 -4
  14. package/web/package.json +11 -0
  15. package/web/server.js +606 -211
  16. package/web/src/App.jsx +54 -12
  17. package/web/src/components/analytics/AnalyticsView.jsx +10 -4
  18. package/web/src/components/atoms/AILatencyCell.jsx +38 -0
  19. package/web/src/components/atoms/AILatencyCell.module.css +43 -0
  20. package/web/src/components/atoms/HealthCell.jsx +53 -0
  21. package/web/src/components/atoms/HealthCell.module.css +15 -0
  22. package/web/src/components/atoms/LastPingCell.jsx +35 -0
  23. package/web/src/components/atoms/LastPingCell.module.css +35 -0
  24. package/web/src/components/atoms/MoodCell.jsx +25 -0
  25. package/web/src/components/atoms/MoodCell.module.css +6 -0
  26. package/web/src/components/atoms/RankCell.jsx +9 -0
  27. package/web/src/components/atoms/RankCell.module.css +9 -0
  28. package/web/src/components/atoms/TPSCell.jsx +36 -0
  29. package/web/src/components/atoms/TPSCell.module.css +38 -0
  30. package/web/src/components/atoms/VerdictBadge.jsx +30 -7
  31. package/web/src/components/atoms/VerdictBadge.module.css +24 -15
  32. package/web/src/components/dashboard/ExportModal.jsx +9 -4
  33. package/web/src/components/dashboard/FilterBar.jsx +112 -10
  34. package/web/src/components/dashboard/FilterBar.module.css +86 -1
  35. package/web/src/components/dashboard/ModelTable.jsx +293 -52
  36. package/web/src/components/dashboard/ModelTable.module.css +131 -33
  37. package/web/src/components/dashboard/StatsBar.jsx +7 -5
  38. package/web/src/components/layout/Footer.jsx +1 -1
  39. package/web/src/components/layout/Header.jsx +43 -9
  40. package/web/src/components/layout/Header.module.css +38 -4
  41. package/web/src/components/layout/Sidebar.jsx +19 -11
  42. package/web/src/components/layout/Sidebar.module.css +15 -5
  43. package/web/src/components/settings/SettingsView.jsx +24 -6
  44. package/web/src/components/settings/SettingsView.module.css +0 -1
  45. package/web/src/global.css +70 -73
  46. package/web/src/hooks/useFilter.js +117 -25
  47. package/web/src/hooks/useSSE.js +33 -9
  48. package/web/src/hooks/useSocket.js +200 -0
  49. package/web/vite.config.js +41 -0
  50. package/web/dist/assets/index-CGN-0_A0.css +0 -1
  51. package/web/dist/assets/index-CvMUM9Jr.js +0 -11
package/web/src/App.jsx CHANGED
@@ -1,19 +1,18 @@
1
1
  /**
2
2
  * @file web/src/App.jsx
3
- * @description Root application component — orchestrates all views, layout, SSE connection, and global state.
3
+ * @description Root application component — orchestrates all views, layout, Socket.IO connection, and global state.
4
4
  * 📖 Manages current view (dashboard/settings/analytics), theme toggle, search, filters,
5
- * selected model for detail panel, export modal, and toast notifications.
6
- * Uses useSSE for live data, useFilter for model filtering/sorting, useTheme for dark/light.
5
+ * selected model for detail panel, export modal, toast notifications, ping mode, and benchmark.
6
+ * Uses useSocket for live data, useFilter for model filtering/sorting, useTheme for dark/light.
7
7
  * @functions App → root component with all state and layout composition
8
8
  */
9
9
  import { useState, useCallback, useEffect } from 'react'
10
- import { useSSE } from './hooks/useSSE.js'
10
+ import { useSocket } from './hooks/useSocket.js'
11
11
  import { useFilter } from './hooks/useFilter.js'
12
12
  import { useTheme } from './hooks/useTheme.js'
13
13
  import Header from './components/layout/Header.jsx'
14
14
  import Sidebar from './components/layout/Sidebar.jsx'
15
15
  import Footer from './components/layout/Footer.jsx'
16
- import StatsBar from './components/dashboard/StatsBar.jsx'
17
16
  import FilterBar from './components/dashboard/FilterBar.jsx'
18
17
  import ModelTable from './components/dashboard/ModelTable.jsx'
19
18
  import DetailPanel from './components/dashboard/DetailPanel.jsx'
@@ -26,7 +25,7 @@ import ToastContainer from './components/atoms/ToastContainer.jsx'
26
25
  let toastIdCounter = 0
27
26
 
28
27
  export default function App() {
29
- const { models, connected } = useSSE('/api/events')
28
+ const { models, connected, nextPingAt, isPinging, pingMode, globalBenchmarkRunning, globalBenchmarkTotal, globalBenchmarkCompleted } = useSocket()
30
29
  const { theme, toggle: toggleTheme } = useTheme()
31
30
  const [currentView, setCurrentView] = useState('dashboard')
32
31
  const [selectedModel, setSelectedModel] = useState(null)
@@ -51,6 +50,27 @@ export default function App() {
51
50
  return Object.values(map).sort((a, b) => a.name.localeCompare(b.name))
52
51
  })()
53
52
 
53
+ // ── Global AI Speed Benchmark (Ctrl+U equivalent) ──
54
+ const handleBenchmark = useCallback(async () => {
55
+ if (globalBenchmarkRunning) {
56
+ console.warn('[Benchmark] Global benchmark already in progress.')
57
+ return
58
+ }
59
+ try {
60
+ await fetch('/api/global-benchmark', {
61
+ method: 'POST',
62
+ headers: { 'Content-Type': 'application/json' },
63
+ // 📖 Match the user's current table, not the hidden catalog. If filters/search
64
+ // 📖 hide a model, the benchmark intentionally skips it.
65
+ body: JSON.stringify({
66
+ models: filtered.map((model) => ({ providerKey: model.providerKey, modelId: model.modelId })),
67
+ }),
68
+ })
69
+ } catch (err) {
70
+ console.error('[Benchmark] Failed to start global benchmark:', err.message)
71
+ }
72
+ }, [filtered, globalBenchmarkRunning])
73
+
54
74
  const addToast = useCallback((message, type = 'info') => {
55
75
  const id = ++toastIdCounter
56
76
  setToasts((prev) => [...prev, { id, message, type }])
@@ -60,13 +80,19 @@ export default function App() {
60
80
  setToasts((prev) => prev.filter((t) => t.id !== id))
61
81
  }, [])
62
82
 
63
- const handleSelectModel = useCallback((modelId) => {
64
- const model = models.find((m) => m.modelId === modelId)
65
- if (model) setSelectedModel(model)
66
- }, [models])
83
+ const handleSelectModel = useCallback((model) => {
84
+ setSelectedModel(model)
85
+ }, [])
67
86
 
68
87
  const handleCloseDetail = useCallback(() => setSelectedModel(null), [])
69
88
 
89
+ // ── Ping mode: sync with backend ──
90
+ const handlePingModeChange = useCallback(async (mode) => {
91
+ try {
92
+ await fetch(`/api/ping-mode?action=${mode}`, { method: 'POST' })
93
+ } catch {}
94
+ }, [])
95
+
70
96
  useEffect(() => {
71
97
  const handler = (e) => {
72
98
  if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
@@ -88,6 +114,7 @@ export default function App() {
88
114
  currentView={currentView}
89
115
  onNavigate={setCurrentView}
90
116
  onToggleTheme={toggleTheme}
117
+ theme={theme}
91
118
  />
92
119
 
93
120
  <div className="app-content">
@@ -99,8 +126,13 @@ export default function App() {
99
126
  onToggleTheme={toggleTheme}
100
127
  onOpenSettings={() => setCurrentView('settings')}
101
128
  onOpenExport={() => setExportOpen(true)}
129
+ onBenchmark={handleBenchmark}
130
+ benchmarkRunning={globalBenchmarkRunning}
131
+ benchmarkTotal={globalBenchmarkTotal}
132
+ benchmarkCompleted={globalBenchmarkCompleted}
133
+ modelsCount={filtered.length}
134
+ theme={theme}
102
135
  />
103
- <StatsBar models={models} />
104
136
  <FilterBar
105
137
  filterTier={filterTier}
106
138
  setFilterTier={setFilterTier}
@@ -109,10 +141,20 @@ export default function App() {
109
141
  filterProvider={filterProvider}
110
142
  setFilterProvider={setFilterProvider}
111
143
  providers={providers}
144
+ pingMode={pingMode}
145
+ setPingMode={handlePingModeChange}
146
+ nextPingAt={nextPingAt}
147
+ isPinging={isPinging}
148
+ globalBenchmarkRunning={globalBenchmarkRunning}
149
+ globalBenchmarkTotal={globalBenchmarkTotal}
150
+ globalBenchmarkCompleted={globalBenchmarkCompleted}
112
151
  />
113
152
  <ModelTable
114
153
  filtered={filtered}
115
154
  onSelectModel={handleSelectModel}
155
+ sortColumn={sortColumn}
156
+ sortDirection={sortDirection}
157
+ onSort={toggleSort}
116
158
  />
117
159
  </div>
118
160
  )}
@@ -154,4 +196,4 @@ export default function App() {
154
196
  <ToastContainer toasts={toasts} dismissToast={dismissToast} />
155
197
  </>
156
198
  )
157
- }
199
+ }
@@ -5,6 +5,7 @@
5
5
  * @functions AnalyticsView → renders the three analytics cards
6
6
  */
7
7
  import { useMemo } from 'react'
8
+ import { IconActivity, IconTrophy } from '@tabler/icons-react'
8
9
  import TierBadge from '../atoms/TierBadge.jsx'
9
10
  import styles from './AnalyticsView.module.css'
10
11
 
@@ -40,7 +41,10 @@ export default function AnalyticsView({ models }) {
40
41
  return (
41
42
  <div className={styles.page}>
42
43
  <div className={styles.pageHeader}>
43
- <h1 className={styles.pageTitle}>📊 Analytics</h1>
44
+ <h1 className={styles.pageTitle}>
45
+ <IconActivity size={24} stroke={1.5} style={{ marginRight: 8, verticalAlign: 'middle' }} />
46
+ Analytics
47
+ </h1>
44
48
  <p className={styles.pageSubtitle}>Real-time insights across all providers and models</p>
45
49
  </div>
46
50
 
@@ -69,17 +73,19 @@ export default function AnalyticsView({ models }) {
69
73
  </div>
70
74
 
71
75
  <div className={styles.card}>
72
- <h3 className={styles.cardTitle}>🏆 Fastest Models</h3>
76
+ <h3 className={styles.cardTitle} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
77
+ <IconTrophy size={16} stroke={1.5} />
78
+ Fastest Models
79
+ </h3>
73
80
  <div className={styles.cardBody}>
74
81
  {leaderboard.length === 0 ? (
75
82
  <div className={styles.empty}>Waiting for ping data...</div>
76
83
  ) : (
77
84
  leaderboard.map((m, i) => {
78
85
  const rankCls = i < 3 ? styles[`rank${i + 1}`] : ''
79
- const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : (i + 1)
80
86
  return (
81
87
  <div key={m.modelId} className={styles.leaderItem}>
82
- <div className={`${styles.leaderRank} ${rankCls}`}>{medal}</div>
88
+ <div className={`${styles.leaderRank} ${rankCls}`}>{i + 1}</div>
83
89
  <span className={styles.leaderName}>{m.label}</span>
84
90
  <span className={styles.leaderLatency}>{m.avg}ms</span>
85
91
  </div>
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @file web/src/components/atoms/AILatencyCell.jsx
3
+ * @description Benchmark AI latency column — shows animated spinner during benchmarks.
4
+ * 📖 Shows: 4.3s, 12s, ERR, TIMEOUT. Retry badge ↻N in blue.
5
+ * 📖 When isRunning: animated CSS spinner + pulsing row highlight.
6
+ */
7
+ import styles from './AILatencyCell.module.css'
8
+
9
+ function formatLatency(result, isRunning) {
10
+ if (isRunning) return { text: 'RUN', badge: '' }
11
+ if (!result || !result.ok) return { text: result?.code || '—', badge: '' }
12
+ const totalSec = result.totalMs / 1000
13
+ const badge = result.retries > 0 ? `↻${result.retries}` : ''
14
+ const text = totalSec >= 10 ? `${totalSec.toFixed(0)}s` : `${totalSec.toFixed(1)}s`
15
+ return { text, badge }
16
+ }
17
+
18
+ export default function AILatencyCell({ result, isRunning }) {
19
+ const { text, badge } = formatLatency(result, isRunning)
20
+ const ok = result?.ok
21
+ const colorCls = ok ? styles.fast : (result ? styles.slow : styles.dim)
22
+
23
+ return (
24
+ <span className={`${styles.cell} ${isRunning ? styles.runningCell : ''}`}>
25
+ {isRunning ? (
26
+ <>
27
+ <span className={styles.benchmarkSpinner} />
28
+ <span className={`${styles.value} ${styles.running}`}>{text}</span>
29
+ </>
30
+ ) : (
31
+ <>
32
+ <span className={`${styles.value} ${colorCls}`}>{text}</span>
33
+ {badge && <span className={styles.badge}>{badge}</span>}
34
+ </>
35
+ )}
36
+ </span>
37
+ )
38
+ }
@@ -0,0 +1,43 @@
1
+ .cell {
2
+ font-family: var(--font-mono);
3
+ font-size: 12px;
4
+ font-weight: 600;
5
+ display: inline-flex;
6
+ align-items: center;
7
+ gap: 4px;
8
+ min-width: 70px;
9
+ transition: background 200ms;
10
+ border-radius: 4px;
11
+ padding: 1px 3px;
12
+ }
13
+ .value { }
14
+ .fast { color: #00ff88; }
15
+ .medium { color: #ffaa00; }
16
+ .slow { color: #ff4444; }
17
+ .dim { color: var(--color-text-dim); }
18
+ .running { color: #b400ff; font-weight: 700; }
19
+ .badge { color: #06b6d4; font-size: 10px; }
20
+
21
+ /* ── Running/benchmarking row highlight ── */
22
+ .runningCell {
23
+ background: rgba(180, 0, 255, 0.12);
24
+ }
25
+
26
+ /* ── Benchmark spinner: animated ring ── */
27
+ @keyframes benchSpin {
28
+ to { transform: rotate(360deg); }
29
+ }
30
+ @keyframes benchPulse {
31
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(180, 0, 255, 0.4); }
32
+ 50% { box-shadow: 0 0 0 4px rgba(180, 0, 255, 0); }
33
+ }
34
+ .benchmarkSpinner {
35
+ display: inline-block;
36
+ width: 12px;
37
+ height: 12px;
38
+ border: 2px solid rgba(180, 0, 255, 0.3);
39
+ border-top-color: #b400ff;
40
+ border-radius: 50%;
41
+ animation: benchSpin 0.6s linear infinite;
42
+ flex-shrink: 0;
43
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @file web/src/components/atoms/HealthCell.jsx
3
+ * @description Detailed health/status column — matches CLI Health column.
4
+ * 📖 Shows: ✅ UP, 🔑 NO KEY, 🔐 AUTH FAIL, ⏳ wait, ⏳ TIMEOUT,
5
+ * 🔥 429 TRY LATER, 🚫 404, 💥 500, 🔌 502, 🔒 503, ⏰ 504, ❌ ERROR.
6
+ */
7
+ import styles from './HealthCell.module.css'
8
+
9
+ const ERROR_LABELS = {
10
+ '404': '404 NOT FOUND',
11
+ '410': '410 GONE',
12
+ '429': '429 TRY LATER',
13
+ '500': '500 ERROR',
14
+ '502': '502 ERROR',
15
+ '503': '503 ERROR',
16
+ '504': '504 TIMEOUT',
17
+ }
18
+
19
+ const STATUS_EMOJI = {
20
+ up: '✅',
21
+ timeout: '⏳',
22
+ down: '❌',
23
+ pending: '⏳',
24
+ noauth: '🔑',
25
+ auth_error: '🔐',
26
+ }
27
+
28
+ function statusLabel(status, httpCode) {
29
+ const emoji = STATUS_EMOJI[status] || '?'
30
+ if (status === 'noauth') return `${emoji} NO KEY`
31
+ if (status === 'auth_error') return `${emoji} AUTH FAIL`
32
+ if (status === 'pending') return `${emoji} wait`
33
+ if (status === 'timeout') return `${emoji} TIMEOUT`
34
+ if (status === 'down') {
35
+ const label = ERROR_LABELS[httpCode] || (httpCode || 'ERR')
36
+ const errEmoji = { '429': '🔥', '404': '🚫', '500': '💥', '502': '🔌', '503': '🔒', '504': '⏰' }[httpCode] || '❌'
37
+ return `${errEmoji} ${label}`
38
+ }
39
+ if (status === 'up') return `${emoji} UP`
40
+ return `${emoji} ?`
41
+ }
42
+
43
+ export default function HealthCell({ status, httpCode }) {
44
+ const text = statusLabel(status, httpCode)
45
+ const cls = status === 'up' ? styles.up
46
+ : status === 'down' ? styles.error
47
+ : status === 'timeout' ? styles.warning
48
+ : status === 'noauth' ? styles.dim
49
+ : status === 'auth_error' ? styles.errorBold
50
+ : status === 'pending' ? styles.warning
51
+ : styles.dim
52
+ return <span className={`${styles.cell} ${cls}`}>{text}</span>
53
+ }
@@ -0,0 +1,15 @@
1
+ .cell {
2
+ font-family: var(--font-mono);
3
+ font-size: 11px;
4
+ font-weight: 600;
5
+ display: inline-block;
6
+ letter-spacing: 0.2px;
7
+ white-space: nowrap;
8
+ overflow: hidden;
9
+ text-overflow: ellipsis;
10
+ }
11
+ .up { color: #00ff88; }
12
+ .warning { color: #ffaa00; }
13
+ .error { color: #ff4444; }
14
+ .errorBold { color: #ff4444; font-weight: 800; }
15
+ .dim { color: var(--color-text-dim); }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @file web/src/components/atoms/LastPingCell.jsx
3
+ * @description Last ping latency number with color coding + animated spinner during pings.
4
+ * 📖 Values are milliseconds, displayed without the `ms` suffix to keep table cells compact.
5
+ * 📖 During ping rounds: animated CSS spinner shows which models are being tested.
6
+ */
7
+ import styles from './LastPingCell.module.css'
8
+
9
+ function pingClass(ms) {
10
+ if (ms == null || ms === Infinity) return styles.none
11
+ if (ms < 500) return styles.fast
12
+ if (ms < 1500) return styles.medium
13
+ return styles.slow
14
+ }
15
+
16
+ export default function LastPingCell({ ms, isPinging }) {
17
+ if (ms == null) {
18
+ return (
19
+ <span className={`${styles.cell} ${styles.none}`}>
20
+ {isPinging ? (
21
+ <span className={styles.spinner} title="Testing…" />
22
+ ) : (
23
+ '—'
24
+ )}
25
+ </span>
26
+ )
27
+ }
28
+
29
+ return (
30
+ <span className={`${styles.cell} ${pingClass(ms)}`}>
31
+ <span className={styles.value}>{ms}</span>
32
+ {isPinging && <span className={styles.spinner} />}
33
+ </span>
34
+ )
35
+ }
@@ -0,0 +1,35 @@
1
+ .cell {
2
+ font-family: var(--font-mono);
3
+ font-size: 12px;
4
+ font-weight: 600;
5
+ display: inline-flex;
6
+ align-items: center;
7
+ gap: 4px;
8
+ min-width: 54px;
9
+ }
10
+ .value { }
11
+ .fast { color: #00ff88; }
12
+ .medium { color: #ffaa00; }
13
+ .slow { color: #ff4444; }
14
+ .none { color: var(--color-text-dim); }
15
+
16
+ /* ── Animated spinner for active pings ── */
17
+ @keyframes pingPulse {
18
+ 0%, 100% { opacity: 1; }
19
+ 50% { opacity: 0.3; }
20
+ }
21
+ @keyframes pingRing {
22
+ 0% { transform: rotate(0deg) scale(1); opacity: 1; }
23
+ 70% { transform: rotate(180deg) scale(0.6); opacity: 0.6; }
24
+ 100% { transform: rotate(360deg) scale(1); opacity: 1; }
25
+ }
26
+ .spinner {
27
+ display: inline-block;
28
+ width: 10px;
29
+ height: 10px;
30
+ border: 1.5px solid rgba(0, 255, 136, 0.25);
31
+ border-top-color: #00ff88;
32
+ border-radius: 50%;
33
+ animation: pingRing 0.8s linear infinite;
34
+ flex-shrink: 0;
35
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @file web/src/components/atoms/MoodCell.jsx
3
+ * @description Tiny verdict indicator emoji (1st column) matching CLI ❔ column.
4
+ * 📖 Mirrors the full Verdict as a compact emoji: 🟩 Perfect, 🟢 Normal, 🟡 Spiky,
5
+ * 🟠 Slow, 🔴 Very Slow, 🔥 Overloaded, 🟥 Unstable, ⚫ Not Active, ⏳ Pending.
6
+ */
7
+ import styles from './MoodCell.module.css'
8
+
9
+ const VERDICT_EMOJI = {
10
+ Perfect: '🟩',
11
+ Normal: '🟢',
12
+ Spiky: '🟡',
13
+ Slow: '🟠',
14
+ 'Very Slow': '🔴',
15
+ Overloaded: '🔥',
16
+ Unstable: '🟥',
17
+ 'Not Active':'⚫',
18
+ Pending: '⏳',
19
+ Usable: '🟠', // fallback
20
+ }
21
+
22
+ export default function MoodCell({ verdict }) {
23
+ const emoji = VERDICT_EMOJI[verdict] || '❔'
24
+ return <span className={styles.mood}>{emoji}</span>
25
+ }
@@ -0,0 +1,6 @@
1
+ .mood {
2
+ display: inline-block;
3
+ font-size: 14px;
4
+ text-align: center;
5
+ width: 20px;
6
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @file web/src/components/atoms/RankCell.jsx
3
+ * @description Row index number — triable column, matches CLI Rank (idx).
4
+ */
5
+ import styles from './RankCell.module.css'
6
+
7
+ export default function RankCell({ index }) {
8
+ return <span className={styles.rank}>{index}</span>
9
+ }
@@ -0,0 +1,9 @@
1
+ .rank {
2
+ display: inline-block;
3
+ font-family: var(--font-mono);
4
+ font-size: 12px;
5
+ font-weight: 600;
6
+ color: var(--color-text-dim);
7
+ text-align: center;
8
+ min-width: 28px;
9
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @file web/src/components/atoms/TPSCell.jsx
3
+ * @description Benchmark tokens-per-second column — animated spinner during benchmarks.
4
+ * 📖 Shows: 13, 45, —. Retry badge ↻N in blue.
5
+ * 📖 When isRunning: animated CSS spinner matches AILatencyCell.
6
+ */
7
+ import styles from './TPSCell.module.css'
8
+
9
+ function formatTps(result, isRunning) {
10
+ if (isRunning) return { text: '…', badge: '' }
11
+ if (!result || !result.ok) return { text: '—', badge: '' }
12
+ const badge = result.retries > 0 ? `↻${result.retries}` : ''
13
+ return { text: String(Math.round(result.tokensPerSecond ?? 0)), badge }
14
+ }
15
+
16
+ export default function TPSCell({ result, isRunning }) {
17
+ const { text, badge } = formatTps(result, isRunning)
18
+ const ok = result?.ok
19
+ const colorCls = ok ? styles.fast : (result ? styles.slow : styles.dim)
20
+
21
+ return (
22
+ <span className={`${styles.cell} ${isRunning ? styles.runningCell : ''}`}>
23
+ {isRunning ? (
24
+ <>
25
+ <span className={styles.miniSpinner} />
26
+ <span className={`${styles.value} ${styles.running}`}>{text}</span>
27
+ </>
28
+ ) : (
29
+ <>
30
+ <span className={`${styles.value} ${colorCls}`}>{text}</span>
31
+ {badge && <span className={styles.badge}>{badge}</span>}
32
+ </>
33
+ )}
34
+ </span>
35
+ )
36
+ }
@@ -0,0 +1,38 @@
1
+ .cell {
2
+ font-family: var(--font-mono);
3
+ font-size: 12px;
4
+ font-weight: 600;
5
+ display: inline-flex;
6
+ align-items: center;
7
+ gap: 4px;
8
+ min-width: 40px;
9
+ transition: background 200ms;
10
+ border-radius: 4px;
11
+ padding: 1px 3px;
12
+ }
13
+ .value { }
14
+ .fast { color: #00ff88; }
15
+ .slow { color: #ff4444; }
16
+ .dim { color: var(--color-text-dim); }
17
+ .running { color: #b400ff; font-weight: 700; }
18
+ .badge { color: #06b6d4; font-size: 10px; }
19
+
20
+ /* ── Running row highlight ── */
21
+ .runningCell {
22
+ background: rgba(180, 0, 255, 0.12);
23
+ }
24
+
25
+ /* ── Mini spinner matching AILatencyCell ── */
26
+ @keyframes miniSpin {
27
+ to { transform: rotate(360deg); }
28
+ }
29
+ .miniSpinner {
30
+ display: inline-block;
31
+ width: 10px;
32
+ height: 10px;
33
+ border: 1.5px solid rgba(180, 0, 255, 0.3);
34
+ border-top-color: #b400ff;
35
+ border-radius: 50%;
36
+ animation: miniSpin 0.6s linear infinite;
37
+ flex-shrink: 0;
38
+ }
@@ -1,13 +1,36 @@
1
1
  /**
2
2
  * @file web/src/components/atoms/VerdictBadge.jsx
3
- * @description Renders a verdict badge (Perfect, Normal, Slow, etc.) with styled pill.
3
+ * @description Renders a verdict badge matching exactly the TUI format.
4
+ * 📖 Shows emoji + text: 🟩 Perfect, 🟢 Normal, 🟡 Spiky, 🟠 Slow, 🔴 Very Slow, 🔥 Overloaded, 🟥 Unstable, ⚫ Not Active, ⏳ Pending.
4
5
  */
5
- import { verdictCls } from '../../utils/ranks.js'
6
6
  import styles from './VerdictBadge.module.css'
7
7
 
8
- export default function VerdictBadge({ verdict, httpCode }) {
9
- if (!verdict) return <span className={`${styles.badge} ${styles.pending}`}>Pending</span>
10
- if (httpCode === '429') return <span className={`${styles.badge} ${styles.ratelimited}`}>⚠️ Rate Limited</span>
11
- const cls = verdictCls(verdict)
12
- return <span className={`${styles.badge} ${styles[cls]}`}>{verdict}</span>
8
+ // ─── Emoji map matching TUI render-table.js verdictIcon ──────────────────────────
9
+ const VERDICT_WITH_EMOJI = {
10
+ Perfect: { emoji: '🟩', text: 'Perfect', cls: 'perfect' },
11
+ Normal: { emoji: '🟢', text: 'Normal', cls: 'normal' },
12
+ Spiky: { emoji: '🟡', text: 'Spiky', cls: 'spiky' },
13
+ Slow: { emoji: '🟠', text: 'Slow', cls: 'slow' },
14
+ 'Very Slow': { emoji: '🔴', text: 'Very Slow', cls: 'veryslow' },
15
+ Overloaded: { emoji: '🔥', text: 'Overloaded', cls: 'overloaded' },
16
+ Unstable: { emoji: '🟥', text: 'Unstable', cls: 'unstable' },
17
+ 'Not Active':{ emoji: '⚫', text: 'Not Active', cls: 'notactive' },
18
+ Pending: { emoji: '⏳', text: 'Pending', cls: 'pending' },
13
19
  }
20
+
21
+ const DEFAULT_ENTRY = { emoji: '❔', text: 'Pending', cls: 'pending' }
22
+
23
+ export default function VerdictBadge({ verdict, httpCode }) {
24
+ // Handle 429 rate limit from HTTP code (TUI shows 🔥 429 TRY LATER in Health column)
25
+ // In Verdict column, TUI shows 'Overloaded' for 429 — keep same behavior
26
+ const entry = verdict
27
+ ? (VERDICT_WITH_EMOJI[verdict] || { emoji: '❔', text: verdict, cls: 'pending' })
28
+ : DEFAULT_ENTRY
29
+
30
+ return (
31
+ <span className={`${styles.badge} ${styles[entry.cls]}`}>
32
+ <span className={styles.emoji}>{entry.emoji}</span>
33
+ <span className={styles.text}>{entry.text}</span>
34
+ </span>
35
+ )
36
+ }
@@ -1,19 +1,28 @@
1
+ /**
2
+ * @file web/src/components/atoms/VerdictBadge.module.css
3
+ * @description Styles matching TUI verdict colors (emoji + text).
4
+ */
1
5
  .badge {
2
- display: inline-block;
3
- font-size: 10px;
6
+ display: inline-flex;
7
+ align-items: center;
8
+ gap: 3px;
9
+ font-size: 11px;
4
10
  font-weight: 700;
5
- text-transform: uppercase;
6
- padding: 2px 8px;
11
+ padding: 2px 7px;
7
12
  border-radius: 999px;
8
- letter-spacing: 0.5px;
13
+ white-space: nowrap;
9
14
  }
10
- .perfect { background: rgba(0,255,136,0.12); color: #00ff88; border: 1px solid rgba(0,255,136,0.25); }
11
- .normal { background: rgba(118,185,0,0.12); color: #76b900; border: 1px solid rgba(118,185,0,0.25); }
12
- .slow { background: rgba(255,170,0,0.12); color: #ffaa00; border: 1px solid rgba(255,170,0,0.25); }
13
- .spiky { background: rgba(255,102,0,0.12); color: #ff6600; border: 1px solid rgba(255,102,0,0.25); }
14
- .veryslow { background: rgba(255,68,68,0.12); color: #ff4444; border: 1px solid rgba(255,68,68,0.2); }
15
- .overloaded { background: rgba(255,34,34,0.12); color: #ff2222; border: 1px solid rgba(255,34,34,0.2); }
16
- .unstable { background: rgba(204,0,0,0.12); color: #cc0000; border: 1px solid rgba(204,0,0,0.2); }
17
- .notactive { background: rgba(85,85,112,0.12); color: #555570; border: 1px solid rgba(85,85,112,0.15); }
18
- .pending { background: rgba(68,68,96,0.1); color: #444460; border: 1px solid rgba(68,68,96,0.15); }
19
- .ratelimited { background: rgba(255,170,0,0.15); color: #ffaa00; border: 1px solid rgba(255,170,0,0.3); }
15
+
16
+ .emoji { font-size: 12px; line-height: 1; }
17
+ .text { line-height: 1; }
18
+
19
+ /* ─── Color variants matching TUI theme ─── */
20
+ .perfect { background: rgba(0,255,136,0.12); color: #00ff88; }
21
+ .normal { background: rgba(118,185,0,0.12); color: #76b900; }
22
+ .slow { background: rgba(255,170,0,0.12); color: #ffaa00; }
23
+ .spiky { background: rgba(255,102,0,0.12); color: #ff6600; }
24
+ .veryslow { background: rgba(255,68,68,0.12); color: #ff4444; }
25
+ .overloaded { background: rgba(255,34,34,0.12); color: #ff2222; }
26
+ .unstable { background: rgba(204,0,0,0.12); color: #cc0000; }
27
+ .notactive { background: rgba(85,85,112,0.12); color: #555570; }
28
+ .pending { background: rgba(68,68,96,0.1); color: #444460; }