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
@@ -18,11 +18,22 @@
18
18
  .right { display: flex; align-items: center; gap: 10px; }
19
19
 
20
20
  .logo { display: flex; align-items: center; gap: 8px; }
21
- .logoIcon { font-size: 22px; }
21
+ .logoIcon {
22
+ color: var(--color-brand);
23
+ font-family: var(--font-mono);
24
+ font-size: 22px;
25
+ font-weight: 900;
26
+ display: flex;
27
+ align-items: center;
28
+ }
22
29
  .logoText {
23
30
  font-size: 16px; font-weight: 700;
24
- background: linear-gradient(135deg, var(--color-accent), #06b6d4);
25
- -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
31
+ color: var(--color-text);
32
+ display: flex;
33
+ align-items: center;
34
+ }
35
+ .logoTextHighlight {
36
+ color: var(--color-brand);
26
37
  }
27
38
  .version {
28
39
  font-size: 11px; font-weight: 500; font-family: var(--font-mono);
@@ -63,11 +74,34 @@
63
74
  display: inline-flex; align-items: center; gap: 6px;
64
75
  padding: 6px 14px; font-size: 13px; font-weight: 600;
65
76
  border: 1px solid var(--color-accent); border-radius: 6px;
66
- background: var(--color-accent); color: #000; cursor: pointer;
77
+ background: var(--color-accent); color: var(--color-bg); cursor: pointer;
67
78
  font-family: var(--font-sans); transition: all 150ms;
68
79
  }
69
80
  .primaryBtn:hover { background: var(--color-accent-hover); }
70
81
 
82
+ .benchmarkBtn {
83
+ display: inline-flex; align-items: center; gap: 6px;
84
+ padding: 6px 14px; font-size: 12px; font-weight: 700;
85
+ border: 1px solid var(--color-border); border-radius: 6px;
86
+ background: var(--color-surface); color: var(--color-text);
87
+ cursor: pointer; font-family: var(--font-mono); transition: all 150ms;
88
+ }
89
+ .benchmarkBtn:hover {
90
+ border-color: #00ff88; color: #00ff88; background: rgba(0, 255, 136, 0.08);
91
+ }
92
+ .benchmarkActive {
93
+ border-color: #00ff88 !important; color: #00ff88 !important;
94
+ background: rgba(0, 255, 136, 0.12) !important;
95
+ cursor: not-allowed;
96
+ }
97
+ .benchmarkRunning { display: inline-flex; align-items: center; gap: 6px; }
98
+ @keyframes spin { to { transform: rotate(360deg); } }
99
+ .spinner {
100
+ display: inline-block; width: 10px; height: 10px;
101
+ border: 2px solid rgba(0, 255, 136, 0.3); border-top-color: #00ff88;
102
+ border-radius: 50%; animation: spin 0.8s linear infinite; flex-shrink: 0;
103
+ }
104
+
71
105
  @media (max-width: 768px) {
72
106
  .center { display: none; }
73
107
  }
@@ -2,24 +2,29 @@
2
2
  * @file web/src/components/layout/Sidebar.jsx
3
3
  * @description Collapsible sidebar navigation with Dashboard / Settings / Analytics links + theme toggle.
4
4
  */
5
+ import { IconBolt, IconLayoutDashboard, IconSettings, IconActivity, IconGlobe, IconMoon, IconSun } from '@tabler/icons-react'
5
6
  import styles from './Sidebar.module.css'
6
7
 
7
- const NAV_ITEMS = [
8
- { id: 'dashboard', icon: '▤', label: 'Dashboard' },
9
- { id: 'settings', icon: '⚙', label: 'Settings' },
10
- { id: 'analytics', icon: '▌▌', label: 'Analytics' },
11
- { id: 'map', icon: '🌍', label: 'Map' },
12
- ]
8
+ export default function Sidebar({ currentView, onNavigate, onToggleTheme, theme }) {
9
+ const navItems = [
10
+ { id: 'dashboard', icon: <IconLayoutDashboard size={20} stroke={1.5} />, label: 'Dashboard' },
11
+ { id: 'settings', icon: <IconSettings size={20} stroke={1.5} />, label: 'Settings' },
12
+ { id: 'analytics', icon: <IconActivity size={20} stroke={1.5} />, label: 'Analytics' },
13
+ { id: 'map', icon: <IconGlobe size={20} stroke={1.5} />, label: 'Map' },
14
+ ]
13
15
 
14
- export default function Sidebar({ currentView, onNavigate, onToggleTheme }) {
15
16
  return (
16
17
  <aside className={styles.sidebar}>
17
18
  <div className={styles.logo}>
18
- <span className={styles.logoIcon}>⚡</span>
19
- <span className={styles.logoText}>FCM</span>
19
+ <span className={styles.logoIcon}>&gt;</span>
20
+ <span className={styles.logoText}>
21
+ <span className={styles.logoTextHighlight}>F</span>
22
+ <span>CM</span>
23
+ <span className={styles.logoTextHighlight}>_</span>
24
+ </span>
20
25
  </div>
21
26
  <nav className={styles.nav}>
22
- {NAV_ITEMS.map(({ id, icon, label }) => (
27
+ {navItems.map(({ id, icon, label }) => (
23
28
  <button
24
29
  key={id}
25
30
  className={`${styles.navItem} ${currentView === id ? styles.active : ''}`}
@@ -33,10 +38,13 @@ export default function Sidebar({ currentView, onNavigate, onToggleTheme }) {
33
38
  </nav>
34
39
  <div className={styles.bottom}>
35
40
  <button className={styles.navItem} onClick={onToggleTheme} title="Toggle Theme">
36
- <span className={styles.navIcon}>☽</span>
41
+ <span className={styles.navIcon}>
42
+ {theme === 'light' ? <IconMoon size={20} stroke={1.5} /> : <IconSun size={20} stroke={1.5} />}
43
+ </span>
37
44
  <span className={styles.navLabel}>Theme</span>
38
45
  </button>
39
46
  </div>
40
47
  </aside>
41
48
  )
42
49
  }
50
+
@@ -22,16 +22,26 @@
22
22
  white-space: nowrap;
23
23
  overflow: hidden;
24
24
  }
25
- .logoIcon { font-size: 24px; flex-shrink: 0; }
25
+ .logoIcon {
26
+ color: var(--color-brand);
27
+ flex-shrink: 0;
28
+ display: flex;
29
+ align-items: center;
30
+ font-family: var(--font-mono);
31
+ font-size: 24px;
32
+ font-weight: 900;
33
+ }
26
34
  .logoText {
27
35
  font-size: 16px;
28
36
  font-weight: 800;
29
- background: linear-gradient(135deg, var(--color-accent), #06b6d4);
30
- -webkit-background-clip: text;
31
- -webkit-text-fill-color: transparent;
32
- background-clip: text;
37
+ color: var(--color-text);
33
38
  opacity: 0;
34
39
  transition: opacity 250ms cubic-bezier(0.16, 1, 0.3, 1);
40
+ display: flex;
41
+ align-items: center;
42
+ }
43
+ .logoTextHighlight {
44
+ color: var(--color-brand);
35
45
  }
36
46
  .sidebar:hover .logoText { opacity: 1; }
37
47
 
@@ -6,6 +6,7 @@
6
6
  * @functions SettingsView → main settings page component
7
7
  */
8
8
  import { useState, useEffect, useCallback } from 'react'
9
+ import { IconSettings, IconPlug, IconCircleCheck, IconKey, IconEye, IconEyeOff, IconCopy, IconTrash } from '@tabler/icons-react'
9
10
  import styles from './SettingsView.module.css'
10
11
  import { maskKey } from '../../utils/format.js'
11
12
 
@@ -160,7 +161,10 @@ export default function SettingsView({ onToast }) {
160
161
  return (
161
162
  <div className={styles.page}>
162
163
  <div className={styles.pageHeader}>
163
- <h1 className={styles.pageTitle}>⚙️ Provider Settings</h1>
164
+ <h1 className={styles.pageTitle}>
165
+ <IconSettings size={24} stroke={1.5} style={{ marginRight: 8, verticalAlign: 'middle' }} />
166
+ Provider Settings
167
+ </h1>
164
168
  <p className={styles.pageSubtitle}>
165
169
  Manage your API keys and provider configurations. Keys are stored locally in{' '}
166
170
  <code>~/.free-coding-models.json</code>
@@ -194,13 +198,23 @@ export default function SettingsView({ onToast }) {
194
198
  return (
195
199
  <div key={key} className={`${styles.card} ${isExpanded ? styles.cardExpanded : ''}`}>
196
200
  <div className={styles.cardHeader} onClick={() => toggleCard(key)}>
197
- <div className={styles.cardIcon}>🔌</div>
201
+ <div className={styles.cardIcon}>
202
+ <IconPlug size={20} stroke={1.5} />
203
+ </div>
198
204
  <div className={styles.cardInfo}>
199
205
  <div className={styles.cardName}>{p.name}</div>
200
206
  <div className={styles.cardMeta}>{p.modelCount} models · {key}</div>
201
207
  </div>
202
208
  <span className={`${styles.cardStatus} ${p.hasKey ? styles.statusConfigured : styles.statusMissing}`}>
203
- {p.hasKey ? '✅ Active' : '🔑 No Key'}
209
+ {p.hasKey ? (
210
+ <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
211
+ <IconCircleCheck size={14} stroke={1.5} /> Active
212
+ </span>
213
+ ) : (
214
+ <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
215
+ <IconKey size={14} stroke={1.5} /> No Key
216
+ </span>
217
+ )}
204
218
  </span>
205
219
  <span className={`${styles.toggleIcon} ${isExpanded ? styles.toggleIconExpanded : ''}`}>▼</span>
206
220
  </div>
@@ -216,10 +230,14 @@ export default function SettingsView({ onToast }) {
216
230
  </span>
217
231
  <div className={styles.keyDisplayActions}>
218
232
  <button className={styles.actionBtn} onClick={() => toggleRevealKey(key)} title={isRevealed ? 'Hide' : 'Reveal'}>
219
- {isRevealed ? '🙈' : '👁️'}
233
+ {isRevealed ? <IconEyeOff size={14} stroke={1.5} /> : <IconEye size={14} stroke={1.5} />}
234
+ </button>
235
+ <button className={styles.actionBtn} onClick={() => copyKey(key)} title="Copy">
236
+ <IconCopy size={14} stroke={1.5} />
237
+ </button>
238
+ <button className={`${styles.actionBtn} ${styles.actionBtnDanger}`} onClick={() => deleteKey(key)} title="Delete Key">
239
+ <IconTrash size={14} stroke={1.5} />
220
240
  </button>
221
- <button className={styles.actionBtn} onClick={() => copyKey(key)} title="Copy">📋</button>
222
- <button className={`${styles.actionBtn} ${styles.actionBtnDanger}`} onClick={() => deleteKey(key)} title="Delete Key">🗑️</button>
223
241
  </div>
224
242
  </div>
225
243
  </div>
@@ -1,6 +1,5 @@
1
1
  .page {
2
2
  padding: 24px;
3
- max-width: 900px;
4
3
  }
5
4
 
6
5
  .pageHeader {
@@ -8,23 +8,24 @@
8
8
 
9
9
  /* ─── CSS Custom Properties (Design Tokens) ─── */
10
10
  :root {
11
- --color-bg: #0a0a0f;
12
- --color-bg-elevated: #12121a;
13
- --color-bg-card: rgba(18, 18, 30, 0.7);
14
- --color-bg-hover: rgba(30, 30, 50, 0.8);
15
- --color-bg-active: rgba(40, 40, 65, 0.9);
16
- --color-surface: #1a1a2e;
17
- --color-border: rgba(255, 255, 255, 0.06);
18
- --color-border-hover: rgba(255, 255, 255, 0.12);
19
-
20
- --color-text: #e8e8f0;
21
- --color-text-muted: #8888a8;
22
- --color-text-dim: #555570;
23
-
24
- --color-accent: #76b900;
25
- --color-accent-hover: #8ad100;
26
- --color-accent-glow: rgba(118, 185, 0, 0.25);
27
- --color-accent-dim: rgba(118, 185, 0, 0.08);
11
+ --color-brand: #76b900;
12
+ --color-bg: #000000;
13
+ --color-bg-elevated: #0a0a0a;
14
+ --color-bg-card: #0a0a0a;
15
+ --color-bg-hover: #161616;
16
+ --color-bg-active: #222222;
17
+ --color-surface: #111111;
18
+ --color-border: #262626;
19
+ --color-border-hover: #404040;
20
+
21
+ --color-text: #ffffff;
22
+ --color-text-muted: #888888;
23
+ --color-text-dim: #444444;
24
+
25
+ --color-accent: #ffffff;
26
+ --color-accent-hover: #eaeaea;
27
+ --color-accent-glow: rgba(255, 255, 255, 0.1);
28
+ --color-accent-dim: rgba(255, 255, 255, 0.05);
28
29
 
29
30
  --color-danger: #ff4444;
30
31
  --color-danger-dim: rgba(255, 68, 68, 0.1);
@@ -60,15 +61,15 @@
60
61
  --header-h: 60px;
61
62
  --sidebar-w: 64px;
62
63
  --sidebar-w-expanded: 200px;
63
- --radius-sm: 6px;
64
- --radius-md: 10px;
65
- --radius-lg: 16px;
66
- --radius-xl: 20px;
64
+ --radius-sm: 4px;
65
+ --radius-md: 6px;
66
+ --radius-lg: 8px;
67
+ --radius-xl: 8px;
67
68
 
68
- --shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
69
- --shadow-md: 0 4px 12px rgba(0,0,0,0.4);
70
- --shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
71
- --shadow-glow: 0 0 30px var(--color-accent-glow);
69
+ --shadow-sm: none;
70
+ --shadow-md: none;
71
+ --shadow-lg: none;
72
+ --shadow-glow: none;
72
73
 
73
74
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
74
75
  --transition-fast: 150ms var(--ease-out);
@@ -78,26 +79,28 @@
78
79
 
79
80
  /* ─── Light Theme ─── */
80
81
  [data-theme="light"] {
81
- --color-bg: #f5f5fa;
82
+ --color-bg: #ffffff;
82
83
  --color-bg-elevated: #ffffff;
83
- --color-bg-card: rgba(255, 255, 255, 0.85);
84
- --color-bg-hover: rgba(240, 240, 248, 0.9);
85
- --color-bg-active: rgba(230, 230, 242, 0.95);
86
- --color-surface: #eeeef4;
87
- --color-border: rgba(0, 0, 0, 0.08);
88
- --color-border-hover: rgba(0, 0, 0, 0.15);
89
- --color-text: #1a1a2e;
90
- --color-text-muted: #666688;
91
- --color-text-dim: #9999aa;
92
- --color-accent-glow: rgba(118, 185, 0, 0.15);
93
- --color-accent-dim: rgba(118, 185, 0, 0.05);
84
+ --color-bg-card: #ffffff;
85
+ --color-bg-hover: #fafafa;
86
+ --color-bg-active: #f0f0f0;
87
+ --color-surface: #fafafa;
88
+ --color-border: #eaeaea;
89
+ --color-border-hover: #d5d5d5;
90
+ --color-text: #000000;
91
+ --color-text-muted: #666666;
92
+ --color-text-dim: #999999;
93
+ --color-accent: #000000;
94
+ --color-accent-hover: #333333;
95
+ --color-accent-glow: rgba(0, 0, 0, 0.05);
96
+ --color-accent-dim: rgba(0, 0, 0, 0.02);
94
97
  --color-danger-dim: rgba(255, 68, 68, 0.08);
95
98
  --color-warning-dim: rgba(255, 170, 0, 0.08);
96
99
  --color-success-dim: rgba(0, 255, 136, 0.08);
97
100
  --color-info-dim: rgba(6, 182, 212, 0.08);
98
- --shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
99
- --shadow-md: 0 4px 12px rgba(0,0,0,0.1);
100
- --shadow-lg: 0 8px 32px rgba(0,0,0,0.12);
101
+ --shadow-sm: none;
102
+ --shadow-md: none;
103
+ --shadow-lg: none;
101
104
  }
102
105
 
103
106
  /* ─── Reset & Base ─── */
@@ -126,41 +129,13 @@ code { font-family: var(--font-mono); font-size: 12px; background: var(--color-s
126
129
 
127
130
  /* ─── Background Effects ─── */
128
131
  .bg-grid {
129
- position: fixed; inset: 0; z-index: 0; pointer-events: none;
130
- background-image:
131
- linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
132
- linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
133
- background-size: 60px 60px;
132
+ display: none;
134
133
  }
135
134
 
136
135
  .bg-glow {
137
- position: fixed; z-index: 0; pointer-events: none;
138
- border-radius: 50%;
139
- filter: blur(120px);
140
- opacity: 0.3;
141
- animation: float 20s ease-in-out infinite;
142
- }
143
- .bg-glow--1 {
144
- width: 600px; height: 600px;
145
- background: radial-gradient(circle, var(--color-accent) 0%, transparent 70%);
146
- top: -200px; left: -200px;
147
- }
148
- .bg-glow--2 {
149
- width: 400px; height: 400px;
150
- background: radial-gradient(circle, #6366f1 0%, transparent 70%);
151
- top: 50%; right: -150px; animation-delay: -7s;
152
- }
153
- .bg-glow--3 {
154
- width: 500px; height: 500px;
155
- background: radial-gradient(circle, #06b6d4 0%, transparent 70%);
156
- bottom: -200px; left: 30%; animation-delay: -14s;
136
+ display: none;
157
137
  }
158
138
 
159
- @keyframes float {
160
- 0%, 100% { transform: translate(0, 0) scale(1); }
161
- 33% { transform: translate(30px, -30px) scale(1.05); }
162
- 66% { transform: translate(-20px, 20px) scale(0.95); }
163
- }
164
139
 
165
140
  [data-theme="light"] .bg-glow { opacity: 0.12; }
166
141
  [data-theme="light"] .bg-grid {
@@ -188,12 +163,34 @@ code { font-family: var(--font-mono); font-size: 12px; background: var(--color-s
188
163
  }
189
164
 
190
165
  /* ─── Scrollbar ─── */
191
- ::-webkit-scrollbar { width: 6px; }
192
- ::-webkit-scrollbar-track { background: transparent; }
193
- ::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 3px; }
166
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
167
+ ::-webkit-scrollbar-track { background: var(--color-bg); }
168
+ ::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 4px; }
194
169
  ::-webkit-scrollbar-thumb:hover { background: var(--color-text-dim); }
170
+ ::-webkit-scrollbar-corner { background: var(--color-bg); }
195
171
 
196
172
  /* ─── Responsive ─── */
197
173
  @media (max-width: 768px) {
198
174
  .app-content { margin-left: 0; }
199
175
  }
176
+
177
+ /* ─── Full-page horizontal/vertical scroll layout ─── */
178
+ .app-content {
179
+ flex: 1;
180
+ margin-left: var(--sidebar-w);
181
+ position: relative;
182
+ z-index: 1;
183
+ min-height: 100vh;
184
+ display: flex;
185
+ flex-direction: column;
186
+ overflow: auto; /* allow scrolling in both directions */
187
+ transition: margin-left var(--transition-medium);
188
+ }
189
+
190
+ .view {
191
+ display: flex;
192
+ flex-direction: column;
193
+ flex: 1;
194
+ overflow: hidden; /* let inner components scroll */
195
+ min-height: 0;
196
+ }
@@ -1,13 +1,69 @@
1
1
  /**
2
2
  * @file web/src/hooks/useFilter.js
3
- * @description React hook for model filtering and sorting state.
4
- * 📖 Manages tier/status/provider/text filters + sort column/direction.
5
- * useFilter
3
+ * @description React hook for model filtering and tri-state sorting state.
4
+ *
5
+ * 📖 Manages tier/status/provider/text filters plus table sorting. Every visible
6
+ * dashboard column can cycle through: ascending → descending → reset. Reset
7
+ * returns the filtered list to catalog/rank order, which is the least surprising
8
+ * neutral state after users have explored a sorted column.
9
+ *
10
+ * Supports all CLI/Web columns: mood, idx, tier, sweScore, ctx, label, origin,
11
+ * latestPing, avg, condition, verdict, stability, uptime, aiLatency, tps, trend.
12
+ *
13
+ * @functions
14
+ * → useFilter(models) — filter + sort state for the dashboard table
15
+ * @exports useFilter
6
16
  */
7
17
  import { useState, useMemo, useCallback } from 'react'
8
18
  import { tierRank, verdictRank, parseSwe } from '../utils/ranks.js'
9
19
  import { formatCtx } from '../utils/format.js'
10
20
 
21
+ function rankOrder(model) {
22
+ return model.idx ?? 9999
23
+ }
24
+
25
+ function pingHistory(model) {
26
+ return model.pingHistory || model.pings || []
27
+ }
28
+
29
+ function latestPingMs(model) {
30
+ const hist = pingHistory(model)
31
+ const latest = hist.length > 0 ? hist[hist.length - 1] : null
32
+ return latest?.ms ?? null
33
+ }
34
+
35
+ function trendDelta(model) {
36
+ const points = pingHistory(model)
37
+ .map((point) => point?.ms)
38
+ .filter((ms) => typeof ms === 'number' && Number.isFinite(ms))
39
+ if (points.length < 2) return null
40
+ return points[points.length - 1] - points[0]
41
+ }
42
+
43
+ function numericOrNull(value) {
44
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
45
+ }
46
+
47
+ function compareNullableNumber(aValue, bValue, direction) {
48
+ const aMissing = aValue == null
49
+ const bMissing = bValue == null
50
+ if (aMissing && bMissing) return 0
51
+ if (aMissing) return 1
52
+ if (bMissing) return -1
53
+ return (aValue - bValue) * direction
54
+ }
55
+
56
+ function compareNullableString(aValue, bValue, direction) {
57
+ const aText = typeof aValue === 'string' ? aValue : ''
58
+ const bText = typeof bValue === 'string' ? bValue : ''
59
+ const aMissing = aText.length === 0
60
+ const bMissing = bText.length === 0
61
+ if (aMissing && bMissing) return 0
62
+ if (aMissing) return 1
63
+ if (bMissing) return -1
64
+ return aText.localeCompare(bText) * direction
65
+ }
66
+
11
67
  export function useFilter(models) {
12
68
  const [filterTier, setFilterTier] = useState('all')
13
69
  const [filterStatus, setFilterStatus] = useState('all')
@@ -17,15 +73,21 @@ export function useFilter(models) {
17
73
  const [sortDirection, setSortDirection] = useState('asc')
18
74
 
19
75
  const toggleSort = useCallback((col) => {
20
- setSortColumn((prevCol) => {
21
- if (prevCol === col) {
22
- setSortDirection((prevDir) => (prevDir === 'asc' ? 'desc' : 'asc'))
23
- } else {
24
- setSortDirection('asc')
25
- }
26
- return col
27
- })
28
- }, [])
76
+ if (sortColumn !== col) {
77
+ setSortColumn(col)
78
+ setSortDirection('asc')
79
+ return
80
+ }
81
+
82
+ if (sortDirection === 'asc') {
83
+ setSortDirection('desc')
84
+ return
85
+ }
86
+
87
+ // 📖 Third click resets the column: no active sort, catalog/rank order.
88
+ setSortColumn(null)
89
+ setSortDirection('asc')
90
+ }, [sortColumn, sortDirection])
29
91
 
30
92
  const filtered = useMemo(() => {
31
93
  let result = [...models]
@@ -53,20 +115,50 @@ export function useFilter(models) {
53
115
  }
54
116
 
55
117
  result.sort((a, b) => {
118
+ if (!sortColumn) return rankOrder(a) - rankOrder(b)
119
+
120
+ const direction = sortDirection === 'desc' ? -1 : 1
56
121
  let cmp = 0
57
- const col = sortColumn
58
- if (col === 'idx') cmp = a.idx - b.idx
59
- else if (col === 'tier') cmp = tierRank(a.tier) - tierRank(b.tier)
60
- else if (col === 'label') cmp = a.label.localeCompare(b.label)
61
- else if (col === 'origin') cmp = a.origin.localeCompare(b.origin)
62
- else if (col === 'sweScore') cmp = parseSwe(a.sweScore) - parseSwe(b.sweScore)
63
- else if (col === 'ctx') cmp = formatCtx(a.ctx) - formatCtx(b.ctx)
64
- else if (col === 'latestPing') cmp = (a.latestPing ?? Infinity) - (b.latestPing ?? Infinity)
65
- else if (col === 'avg') cmp = (a.avg === Infinity ? 99999 : a.avg) - (b.avg === Infinity ? 99999 : b.avg)
66
- else if (col === 'stability') cmp = (a.stability ?? -1) - (b.stability ?? -1)
67
- else if (col === 'verdict') cmp = verdictRank(a.verdict) - verdictRank(b.verdict)
68
- else if (col === 'uptime') cmp = (a.uptime ?? 0) - (b.uptime ?? 0)
69
- return sortDirection === 'asc' ? cmp : -cmp
122
+
123
+ if (sortColumn === 'mood') {
124
+ cmp = compareNullableNumber(verdictRank(a.verdict), verdictRank(b.verdict), direction)
125
+ } else if (sortColumn === 'idx') {
126
+ cmp = compareNullableNumber(rankOrder(a), rankOrder(b), direction)
127
+ } else if (sortColumn === 'tier') {
128
+ cmp = compareNullableNumber(tierRank(a.tier), tierRank(b.tier), direction)
129
+ } else if (sortColumn === 'label') {
130
+ cmp = compareNullableString(a.label, b.label, direction)
131
+ } else if (sortColumn === 'origin') {
132
+ cmp = compareNullableString(a.origin, b.origin, direction)
133
+ } else if (sortColumn === 'sweScore') {
134
+ cmp = compareNullableNumber(parseSwe(a.sweScore), parseSwe(b.sweScore), direction)
135
+ } else if (sortColumn === 'ctx') {
136
+ cmp = compareNullableNumber(formatCtx(a.ctx), formatCtx(b.ctx), direction)
137
+ } else if (sortColumn === 'latestPing') {
138
+ cmp = compareNullableNumber(latestPingMs(a), latestPingMs(b), direction)
139
+ } else if (sortColumn === 'avg') {
140
+ const aAvg = a.avg == null || a.avg === Infinity || a.avg > 99000 ? null : a.avg
141
+ const bAvg = b.avg == null || b.avg === Infinity || b.avg > 99000 ? null : b.avg
142
+ cmp = compareNullableNumber(aAvg, bAvg, direction)
143
+ } else if (sortColumn === 'condition') {
144
+ const healthOrder = { up: 0, timeout: 1, down: 2, pending: 3, noauth: 4, auth_error: 5 }
145
+ cmp = compareNullableNumber(healthOrder[a.status] ?? 9, healthOrder[b.status] ?? 9, direction)
146
+ } else if (sortColumn === 'verdict') {
147
+ cmp = compareNullableNumber(verdictRank(a.verdict), verdictRank(b.verdict), direction)
148
+ } else if (sortColumn === 'stability') {
149
+ cmp = compareNullableNumber(numericOrNull(a.stability), numericOrNull(b.stability), direction)
150
+ } else if (sortColumn === 'uptime') {
151
+ cmp = compareNullableNumber(numericOrNull(a.uptime), numericOrNull(b.uptime), direction)
152
+ } else if (sortColumn === 'aiLatency') {
153
+ cmp = compareNullableNumber(a.benchmark?.ok ? a.benchmark.totalMs : null, b.benchmark?.ok ? b.benchmark.totalMs : null, direction)
154
+ } else if (sortColumn === 'tps') {
155
+ cmp = compareNullableNumber(a.benchmark?.ok ? a.benchmark.tokensPerSecond ?? 0 : null, b.benchmark?.ok ? b.benchmark.tokensPerSecond ?? 0 : null, direction)
156
+ } else if (sortColumn === 'trend') {
157
+ // 📖 Negative delta means latency improved over the visible sparkline; positive means it got slower.
158
+ cmp = compareNullableNumber(trendDelta(a), trendDelta(b), direction)
159
+ }
160
+
161
+ return cmp || (rankOrder(a) - rankOrder(b))
70
162
  })
71
163
 
72
164
  return result
@@ -6,44 +6,68 @@
6
6
  */
7
7
  import { useState, useEffect, useRef, useCallback } from 'react'
8
8
 
9
+ const RECONNECT_DELAY = 2000
10
+
9
11
  export function useSSE(url = '/api/events') {
10
12
  const [models, setModels] = useState([])
11
13
  const [connected, setConnected] = useState(false)
12
14
  const [updateCount, setUpdateCount] = useState(0)
13
15
  const esRef = useRef(null)
14
- const reconnectTimer = useRef(null)
16
+ const reconnectRef = useRef(null)
17
+ const mountedRef = useRef(true)
15
18
 
16
19
  const connect = useCallback(() => {
17
- if (esRef.current) esRef.current.close()
20
+ if (!mountedRef.current) return
21
+
22
+ // Close existing connection
23
+ if (esRef.current) {
24
+ esRef.current.close()
25
+ esRef.current = null
26
+ }
27
+
28
+ clearTimeout(reconnectRef.current)
18
29
 
19
30
  const es = new EventSource(url)
20
31
  esRef.current = es
21
32
 
22
- es.onopen = () => setConnected(true)
33
+ es.onopen = () => {
34
+ if (mountedRef.current) setConnected(true)
35
+ }
36
+
23
37
  es.onmessage = (event) => {
38
+ if (!mountedRef.current) return
24
39
  try {
25
40
  const data = JSON.parse(event.data)
26
41
  setModels(data)
27
- setUpdateCount((c) => c + 1)
42
+ setUpdateCount(c => c + 1)
28
43
  } catch (e) {
29
- console.error('SSE parse error:', e)
44
+ console.warn('[useSSE] parse error:', e)
30
45
  }
31
46
  }
47
+
32
48
  es.onerror = () => {
33
49
  setConnected(false)
34
50
  es.close()
35
- clearTimeout(reconnectTimer.current)
36
- reconnectTimer.current = setTimeout(connect, 3000)
51
+ esRef.current = null
52
+
53
+ if (mountedRef.current) {
54
+ reconnectRef.current = setTimeout(() => {
55
+ if (mountedRef.current) connect()
56
+ }, RECONNECT_DELAY)
57
+ }
37
58
  }
38
59
  }, [url])
39
60
 
40
61
  useEffect(() => {
62
+ mountedRef.current = true
41
63
  connect()
64
+
42
65
  return () => {
66
+ mountedRef.current = false
67
+ clearTimeout(reconnectRef.current)
43
68
  esRef.current?.close()
44
- clearTimeout(reconnectTimer.current)
45
69
  }
46
70
  }, [connect])
47
71
 
48
72
  return { models, connected, updateCount }
49
- }
73
+ }