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.
- package/README.md +9 -1
- package/bin/free-coding-models.js +10 -0
- package/changelog/v0.5.1.md +24 -0
- package/package.json +7 -2
- 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/render-table.js +1 -1
- package/web/README.md +8 -5
- package/web/dist/assets/index-ByGf4Kq-.js +14 -0
- package/web/dist/assets/index-Ds7wmHBv.css +1 -0
- package/web/dist/index.html +3 -6
- package/web/index.html +1 -4
- package/web/package.json +11 -0
- package/web/server.js +606 -211
- package/web/src/App.jsx +54 -12
- package/web/src/components/analytics/AnalyticsView.jsx +10 -4
- package/web/src/components/atoms/AILatencyCell.jsx +38 -0
- package/web/src/components/atoms/AILatencyCell.module.css +43 -0
- package/web/src/components/atoms/HealthCell.jsx +53 -0
- package/web/src/components/atoms/HealthCell.module.css +15 -0
- package/web/src/components/atoms/LastPingCell.jsx +35 -0
- package/web/src/components/atoms/LastPingCell.module.css +35 -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/RankCell.jsx +9 -0
- package/web/src/components/atoms/RankCell.module.css +9 -0
- package/web/src/components/atoms/TPSCell.jsx +36 -0
- package/web/src/components/atoms/TPSCell.module.css +38 -0
- package/web/src/components/atoms/VerdictBadge.jsx +30 -7
- package/web/src/components/atoms/VerdictBadge.module.css +24 -15
- package/web/src/components/dashboard/ExportModal.jsx +9 -4
- package/web/src/components/dashboard/FilterBar.jsx +112 -10
- package/web/src/components/dashboard/FilterBar.module.css +86 -1
- package/web/src/components/dashboard/ModelTable.jsx +293 -52
- package/web/src/components/dashboard/ModelTable.module.css +131 -33
- package/web/src/components/dashboard/StatsBar.jsx +7 -5
- package/web/src/components/layout/Footer.jsx +1 -1
- package/web/src/components/layout/Header.jsx +43 -9
- package/web/src/components/layout/Header.module.css +38 -4
- package/web/src/components/layout/Sidebar.jsx +19 -11
- package/web/src/components/layout/Sidebar.module.css +15 -5
- package/web/src/components/settings/SettingsView.jsx +24 -6
- package/web/src/components/settings/SettingsView.module.css +0 -1
- package/web/src/global.css +70 -73
- package/web/src/hooks/useFilter.js +117 -25
- package/web/src/hooks/useSSE.js +33 -9
- package/web/src/hooks/useSocket.js +200 -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
|
@@ -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 {
|
|
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
|
-
|
|
25
|
-
|
|
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:
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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}
|
|
19
|
-
<span className={styles.logoText}>
|
|
19
|
+
<span className={styles.logoIcon}>></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
|
-
{
|
|
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}
|
|
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 {
|
|
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
|
-
|
|
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}
|
|
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}
|
|
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 ?
|
|
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>
|
package/web/src/global.css
CHANGED
|
@@ -8,23 +8,24 @@
|
|
|
8
8
|
|
|
9
9
|
/* ─── CSS Custom Properties (Design Tokens) ─── */
|
|
10
10
|
:root {
|
|
11
|
-
--color-
|
|
12
|
-
--color-bg
|
|
13
|
-
--color-bg-
|
|
14
|
-
--color-bg-
|
|
15
|
-
--color-bg-
|
|
16
|
-
--color-
|
|
17
|
-
--color-
|
|
18
|
-
--color-border
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
--color-text
|
|
22
|
-
--color-text-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
--color-accent
|
|
26
|
-
--color-accent-
|
|
27
|
-
--color-accent-
|
|
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:
|
|
64
|
-
--radius-md:
|
|
65
|
-
--radius-lg:
|
|
66
|
-
--radius-xl:
|
|
64
|
+
--radius-sm: 4px;
|
|
65
|
+
--radius-md: 6px;
|
|
66
|
+
--radius-lg: 8px;
|
|
67
|
+
--radius-xl: 8px;
|
|
67
68
|
|
|
68
|
-
--shadow-sm:
|
|
69
|
-
--shadow-md:
|
|
70
|
-
--shadow-lg:
|
|
71
|
-
--shadow-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: #
|
|
82
|
+
--color-bg: #ffffff;
|
|
82
83
|
--color-bg-elevated: #ffffff;
|
|
83
|
-
--color-bg-card:
|
|
84
|
-
--color-bg-hover:
|
|
85
|
-
--color-bg-active:
|
|
86
|
-
--color-surface: #
|
|
87
|
-
--color-border:
|
|
88
|
-
--color-border-hover:
|
|
89
|
-
--color-text: #
|
|
90
|
-
--color-text-muted: #
|
|
91
|
-
--color-text-dim: #
|
|
92
|
-
--color-accent
|
|
93
|
-
--color-accent-
|
|
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:
|
|
99
|
-
--shadow-md:
|
|
100
|
-
--shadow-lg:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
192
|
-
::-webkit-scrollbar-track { background:
|
|
193
|
-
::-webkit-scrollbar-thumb { background: var(--color-border); border-radius:
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
else if (
|
|
61
|
-
|
|
62
|
-
else if (
|
|
63
|
-
|
|
64
|
-
else if (
|
|
65
|
-
|
|
66
|
-
else if (
|
|
67
|
-
|
|
68
|
-
else if (
|
|
69
|
-
|
|
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
|
package/web/src/hooks/useSSE.js
CHANGED
|
@@ -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
|
|
16
|
+
const reconnectRef = useRef(null)
|
|
17
|
+
const mountedRef = useRef(true)
|
|
15
18
|
|
|
16
19
|
const connect = useCallback(() => {
|
|
17
|
-
if (
|
|
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 = () =>
|
|
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(
|
|
42
|
+
setUpdateCount(c => c + 1)
|
|
28
43
|
} catch (e) {
|
|
29
|
-
console.
|
|
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
|
-
|
|
36
|
-
|
|
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
|
+
}
|