free-coding-models 0.3.37 → 0.3.40
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/CHANGELOG.md +10 -1794
- package/README.md +4 -1
- package/bin/free-coding-models.js +8 -0
- package/package.json +13 -3
- package/src/app.js +3 -0
- package/src/cli-help.js +2 -0
- package/src/command-palette.js +3 -0
- package/src/endpoint-installer.js +1 -1
- package/src/tool-bootstrap.js +34 -0
- package/src/tool-launchers.js +137 -1
- package/src/tool-metadata.js +9 -0
- package/src/utils.js +10 -0
- package/web/app.legacy.js +900 -0
- package/web/index.html +20 -0
- package/web/server.js +382 -0
- package/web/src/App.jsx +150 -0
- package/web/src/components/analytics/AnalyticsView.jsx +109 -0
- package/web/src/components/analytics/AnalyticsView.module.css +186 -0
- package/web/src/components/atoms/Sparkline.jsx +44 -0
- package/web/src/components/atoms/StabilityCell.jsx +18 -0
- package/web/src/components/atoms/StabilityCell.module.css +8 -0
- package/web/src/components/atoms/StatusDot.jsx +10 -0
- package/web/src/components/atoms/StatusDot.module.css +17 -0
- package/web/src/components/atoms/TierBadge.jsx +10 -0
- package/web/src/components/atoms/TierBadge.module.css +18 -0
- package/web/src/components/atoms/Toast.jsx +25 -0
- package/web/src/components/atoms/Toast.module.css +35 -0
- package/web/src/components/atoms/ToastContainer.jsx +16 -0
- package/web/src/components/atoms/ToastContainer.module.css +10 -0
- package/web/src/components/atoms/VerdictBadge.jsx +13 -0
- package/web/src/components/atoms/VerdictBadge.module.css +19 -0
- package/web/src/components/dashboard/DetailPanel.jsx +131 -0
- package/web/src/components/dashboard/DetailPanel.module.css +99 -0
- package/web/src/components/dashboard/ExportModal.jsx +79 -0
- package/web/src/components/dashboard/ExportModal.module.css +99 -0
- package/web/src/components/dashboard/FilterBar.jsx +73 -0
- package/web/src/components/dashboard/FilterBar.module.css +43 -0
- package/web/src/components/dashboard/ModelTable.jsx +86 -0
- package/web/src/components/dashboard/ModelTable.module.css +46 -0
- package/web/src/components/dashboard/StatsBar.jsx +40 -0
- package/web/src/components/dashboard/StatsBar.module.css +28 -0
- package/web/src/components/layout/Footer.jsx +19 -0
- package/web/src/components/layout/Footer.module.css +10 -0
- package/web/src/components/layout/Header.jsx +38 -0
- package/web/src/components/layout/Header.module.css +73 -0
- package/web/src/components/layout/Sidebar.jsx +41 -0
- package/web/src/components/layout/Sidebar.module.css +76 -0
- package/web/src/components/settings/SettingsView.jsx +264 -0
- package/web/src/components/settings/SettingsView.module.css +377 -0
- package/web/src/global.css +199 -0
- package/web/src/hooks/useFilter.js +83 -0
- package/web/src/hooks/useSSE.js +49 -0
- package/web/src/hooks/useTheme.js +27 -0
- package/web/src/main.jsx +15 -0
- package/web/src/utils/download.js +15 -0
- package/web/src/utils/format.js +42 -0
- package/web/src/utils/ranks.js +37 -0
- package/web/styles.legacy.css +963 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
.page {
|
|
2
|
+
padding: 24px;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.pageHeader {
|
|
6
|
+
margin-bottom: 24px;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.pageTitle {
|
|
10
|
+
font-size: 24px;
|
|
11
|
+
font-weight: 800;
|
|
12
|
+
margin: 0 0 6px;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.pageSubtitle {
|
|
16
|
+
font-size: 13px;
|
|
17
|
+
color: var(--color-text-muted);
|
|
18
|
+
margin: 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.grid {
|
|
22
|
+
display: grid;
|
|
23
|
+
grid-template-columns: repeat(2, 1fr);
|
|
24
|
+
gap: 16px;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.card {
|
|
28
|
+
background: var(--color-bg-card);
|
|
29
|
+
backdrop-filter: blur(12px);
|
|
30
|
+
border: 1px solid var(--color-border);
|
|
31
|
+
border-radius: var(--radius-lg);
|
|
32
|
+
padding: 20px;
|
|
33
|
+
overflow: hidden;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.cardWide {
|
|
37
|
+
grid-column: span 2;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.cardTitle {
|
|
41
|
+
font-size: 14px;
|
|
42
|
+
font-weight: 700;
|
|
43
|
+
margin: 0 0 16px;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.cardBody {
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.empty {
|
|
50
|
+
color: var(--color-text-dim);
|
|
51
|
+
text-align: center;
|
|
52
|
+
padding: 20px 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.healthItem {
|
|
56
|
+
display: flex;
|
|
57
|
+
align-items: center;
|
|
58
|
+
gap: 12px;
|
|
59
|
+
padding: 8px 0;
|
|
60
|
+
border-bottom: 1px solid var(--color-border);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.healthItem:last-child {
|
|
64
|
+
border-bottom: none;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.healthName {
|
|
68
|
+
width: 120px;
|
|
69
|
+
font-size: 12px;
|
|
70
|
+
font-weight: 600;
|
|
71
|
+
flex-shrink: 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.healthBar {
|
|
75
|
+
flex: 1;
|
|
76
|
+
height: 8px;
|
|
77
|
+
border-radius: 4px;
|
|
78
|
+
background: var(--color-surface);
|
|
79
|
+
overflow: hidden;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.healthFill {
|
|
83
|
+
height: 100%;
|
|
84
|
+
border-radius: 4px;
|
|
85
|
+
background: linear-gradient(90deg, var(--color-accent), var(--color-success));
|
|
86
|
+
transition: width var(--transition-medium);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.healthPct {
|
|
90
|
+
width: 40px;
|
|
91
|
+
text-align: right;
|
|
92
|
+
font-family: var(--font-mono);
|
|
93
|
+
font-size: 12px;
|
|
94
|
+
font-weight: 600;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.pctFast { color: #00ff88; }
|
|
98
|
+
.pctMedium { color: #ffaa00; }
|
|
99
|
+
.pctSlow { color: #ff4444; }
|
|
100
|
+
|
|
101
|
+
.leaderItem {
|
|
102
|
+
display: flex;
|
|
103
|
+
align-items: center;
|
|
104
|
+
gap: 10px;
|
|
105
|
+
padding: 8px 0;
|
|
106
|
+
border-bottom: 1px solid var(--color-border);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.leaderItem:last-child {
|
|
110
|
+
border-bottom: none;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.leaderRank {
|
|
114
|
+
width: 28px;
|
|
115
|
+
height: 28px;
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
justify-content: center;
|
|
119
|
+
border-radius: 50%;
|
|
120
|
+
font-size: 12px;
|
|
121
|
+
font-weight: 700;
|
|
122
|
+
background: var(--color-surface);
|
|
123
|
+
color: var(--color-text-dim);
|
|
124
|
+
flex-shrink: 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.rank1 { background: rgba(255, 215, 0, 0.15); color: #ffd700; }
|
|
128
|
+
.rank2 { background: rgba(192, 192, 192, 0.15); color: #c0c0c0; }
|
|
129
|
+
.rank3 { background: rgba(205, 127, 50, 0.15); color: #cd7f32; }
|
|
130
|
+
|
|
131
|
+
.leaderName {
|
|
132
|
+
flex: 1;
|
|
133
|
+
font-size: 12px;
|
|
134
|
+
font-weight: 600;
|
|
135
|
+
min-width: 0;
|
|
136
|
+
overflow: hidden;
|
|
137
|
+
text-overflow: ellipsis;
|
|
138
|
+
white-space: nowrap;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.leaderLatency {
|
|
142
|
+
font-family: var(--font-mono);
|
|
143
|
+
font-size: 12px;
|
|
144
|
+
font-weight: 600;
|
|
145
|
+
color: var(--color-success);
|
|
146
|
+
flex-shrink: 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.tierItem {
|
|
150
|
+
display: flex;
|
|
151
|
+
align-items: center;
|
|
152
|
+
gap: 10px;
|
|
153
|
+
padding: 6px 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.tierBadge {
|
|
157
|
+
width: 40px;
|
|
158
|
+
flex-shrink: 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.tierBar {
|
|
162
|
+
flex: 1;
|
|
163
|
+
height: 10px;
|
|
164
|
+
border-radius: 5px;
|
|
165
|
+
background: var(--color-surface);
|
|
166
|
+
overflow: hidden;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.tierFill {
|
|
170
|
+
height: 100%;
|
|
171
|
+
border-radius: 5px;
|
|
172
|
+
transition: width var(--transition-medium);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.tierCount {
|
|
176
|
+
width: 30px;
|
|
177
|
+
text-align: right;
|
|
178
|
+
font-family: var(--font-mono);
|
|
179
|
+
font-size: 12px;
|
|
180
|
+
color: var(--color-text-muted);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
@media (max-width: 1024px) {
|
|
184
|
+
.grid { grid-template-columns: 1fr; }
|
|
185
|
+
.cardWide { grid-column: span 1; }
|
|
186
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/Sparkline.jsx
|
|
3
|
+
* @description SVG sparkline chart for ping history trend visualization.
|
|
4
|
+
* 📖 Renders a polyline with gradient area fill and endpoint dot.
|
|
5
|
+
*/
|
|
6
|
+
import { useMemo } from 'react'
|
|
7
|
+
|
|
8
|
+
export default function Sparkline({ history }) {
|
|
9
|
+
const svg = useMemo(() => {
|
|
10
|
+
if (!history || history.length < 2) return null
|
|
11
|
+
const valid = history.filter((p) => p.code === '200' || p.code === '401')
|
|
12
|
+
if (valid.length < 2) return null
|
|
13
|
+
|
|
14
|
+
const values = valid.map((p) => p.ms)
|
|
15
|
+
const max = Math.max(...values, 1)
|
|
16
|
+
const min = Math.min(...values, 0)
|
|
17
|
+
const range = max - min || 1
|
|
18
|
+
const w = 80,
|
|
19
|
+
h = 22
|
|
20
|
+
const step = w / (values.length - 1)
|
|
21
|
+
|
|
22
|
+
const points = values
|
|
23
|
+
.map((v, i) => {
|
|
24
|
+
const x = i * step
|
|
25
|
+
const y = h - ((v - min) / range) * (h - 4) - 2
|
|
26
|
+
return `${x.toFixed(1)},${y.toFixed(1)}`
|
|
27
|
+
})
|
|
28
|
+
.join(' ')
|
|
29
|
+
|
|
30
|
+
const lastVal = values[values.length - 1]
|
|
31
|
+
const color = lastVal < 500 ? '#00ff88' : lastVal < 1500 ? '#ffaa00' : '#ff4444'
|
|
32
|
+
const lastX = ((values.length - 1) * step).toFixed(1)
|
|
33
|
+
const lastY = (h - ((lastVal - min) / range) * (h - 4) - 2).toFixed(1)
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<svg className="sparkline-svg" width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
|
|
37
|
+
<polyline fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" points={points} opacity="0.8" />
|
|
38
|
+
<circle cx={lastX} cy={lastY} r="2.5" fill={color} />
|
|
39
|
+
</svg>
|
|
40
|
+
)
|
|
41
|
+
}, [history])
|
|
42
|
+
|
|
43
|
+
return svg || null
|
|
44
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/StabilityCell.jsx
|
|
3
|
+
* @description Renders stability score with a progress bar and numeric value.
|
|
4
|
+
*/
|
|
5
|
+
import styles from './StabilityCell.module.css'
|
|
6
|
+
|
|
7
|
+
export default function StabilityCell({ score }) {
|
|
8
|
+
if (score == null || score < 0) return <span className={styles.none}>—</span>
|
|
9
|
+
const cls = score >= 70 ? 'high' : score >= 40 ? 'mid' : 'low'
|
|
10
|
+
return (
|
|
11
|
+
<div className={styles.cell}>
|
|
12
|
+
<div className={styles.bar}>
|
|
13
|
+
<div className={`${styles.fill} ${styles[cls]}`} style={{ width: `${score}%` }} />
|
|
14
|
+
</div>
|
|
15
|
+
<span className={styles.value}>{score}</span>
|
|
16
|
+
</div>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
.cell { display: flex; align-items: center; gap: 6px; }
|
|
2
|
+
.bar { width: 48px; height: 6px; border-radius: 3px; background: var(--color-surface); overflow: hidden; }
|
|
3
|
+
.fill { height: 100%; border-radius: 3px; transition: width 250ms cubic-bezier(0.16, 1, 0.3, 1); }
|
|
4
|
+
.high { background: linear-gradient(90deg, #00ff88, #76b900); }
|
|
5
|
+
.mid { background: linear-gradient(90deg, #ffaa00, #ff8c00); }
|
|
6
|
+
.low { background: linear-gradient(90deg, #ff4444, #cc0000); }
|
|
7
|
+
.value { font-family: 'JetBrains Mono', monospace; font-size: 12px; font-weight: 600; }
|
|
8
|
+
.none { color: var(--color-text-dim); }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/StatusDot.jsx
|
|
3
|
+
* @description Renders a colored status indicator dot (green=up, red=down, gray=pending).
|
|
4
|
+
*/
|
|
5
|
+
import styles from './StatusDot.module.css'
|
|
6
|
+
|
|
7
|
+
export default function StatusDot({ status }) {
|
|
8
|
+
const cls = status === 'up' ? styles.up : status === 'timeout' ? styles.timeout : status === 'down' ? styles.down : styles.pending
|
|
9
|
+
return <span className={`${styles.dot} ${cls}`} />
|
|
10
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
.dot {
|
|
2
|
+
display: inline-block;
|
|
3
|
+
width: 8px;
|
|
4
|
+
height: 8px;
|
|
5
|
+
border-radius: 50%;
|
|
6
|
+
margin-right: 6px;
|
|
7
|
+
vertical-align: middle;
|
|
8
|
+
}
|
|
9
|
+
.up { background: #00ff88; box-shadow: 0 0 6px rgba(0,255,136,0.4); }
|
|
10
|
+
.down { background: #ff4444; }
|
|
11
|
+
.timeout { background: #ff4444; }
|
|
12
|
+
.pending { background: #555570; animation: pulse 1.5s ease-in-out infinite; }
|
|
13
|
+
|
|
14
|
+
@keyframes pulse {
|
|
15
|
+
0%, 100% { box-shadow: 0 0 0 0 rgba(118,185,0,0.25); }
|
|
16
|
+
50% { box-shadow: 0 0 0 6px transparent; }
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/TierBadge.jsx
|
|
3
|
+
* @description Renders a tier badge (S+, S, A+, etc.) with color-coded styling.
|
|
4
|
+
*/
|
|
5
|
+
import styles from './TierBadge.module.css'
|
|
6
|
+
|
|
7
|
+
export default function TierBadge({ tier }) {
|
|
8
|
+
const cls = tier.replace('+', 'plus').replace('-', 'minus').toLowerCase()
|
|
9
|
+
return <span className={`${styles.badge} ${styles[`tier_${cls}`]}`}>{tier}</span>
|
|
10
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
.badge {
|
|
2
|
+
display: inline-block;
|
|
3
|
+
font-family: 'JetBrains Mono', monospace;
|
|
4
|
+
font-weight: 700;
|
|
5
|
+
font-size: 11px;
|
|
6
|
+
padding: 2px 8px;
|
|
7
|
+
border-radius: 4px;
|
|
8
|
+
text-align: center;
|
|
9
|
+
min-width: 32px;
|
|
10
|
+
}
|
|
11
|
+
.tier_splus { background: rgba(255,215,0,0.15); color: #ffd700; border: 1px solid rgba(255,215,0,0.3); }
|
|
12
|
+
.tier_s { background: rgba(255,140,0,0.12); color: #ff8c00; border: 1px solid rgba(255,140,0,0.25); }
|
|
13
|
+
.tier_aplus { background: rgba(0,200,255,0.1); color: #00c8ff; border: 1px solid rgba(0,200,255,0.2); }
|
|
14
|
+
.tier_a { background: rgba(61,220,132,0.1); color: #3ddc84; border: 1px solid rgba(61,220,132,0.2); }
|
|
15
|
+
.tier_aminus{ background: rgba(126,207,126,0.1); color: #7ecf7e; border: 1px solid rgba(126,207,126,0.2); }
|
|
16
|
+
.tier_bplus { background: rgba(168,168,200,0.1); color: #a8a8c8; border: 1px solid rgba(168,168,200,0.15); }
|
|
17
|
+
.tier_b { background: rgba(128,128,152,0.1); color: #808098; border: 1px solid rgba(128,128,152,0.15); }
|
|
18
|
+
.tier_c { background: rgba(96,96,120,0.1); color: #606078; border: 1px solid rgba(96,96,120,0.15); }
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/Toast.jsx
|
|
3
|
+
* @description Toast notification component with auto-dismiss and animated entrance/exit.
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect, useRef } from 'react'
|
|
6
|
+
import styles from './Toast.module.css'
|
|
7
|
+
|
|
8
|
+
const ICONS = { success: '✅', error: '❌', warning: '⚠️', info: 'ℹ️' }
|
|
9
|
+
|
|
10
|
+
export default function Toast({ message, type = 'info', duration = 3500, onDismiss }) {
|
|
11
|
+
const timerRef = useRef(null)
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
timerRef.current = setTimeout(onDismiss, duration)
|
|
15
|
+
return () => clearTimeout(timerRef.current)
|
|
16
|
+
}, [duration, onDismiss])
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className={`${styles.toast} ${styles[type]}`}>
|
|
20
|
+
<span className={styles.icon}>{ICONS[type] || '📌'}</span>
|
|
21
|
+
<span className={styles.message}>{message}</span>
|
|
22
|
+
<button className={styles.close} onClick={onDismiss}>×</button>
|
|
23
|
+
</div>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
.toast {
|
|
2
|
+
pointer-events: auto;
|
|
3
|
+
display: flex;
|
|
4
|
+
align-items: center;
|
|
5
|
+
gap: 10px;
|
|
6
|
+
padding: 12px 18px;
|
|
7
|
+
border-radius: var(--radius-md, 10px);
|
|
8
|
+
background: var(--color-bg-elevated);
|
|
9
|
+
border: 1px solid var(--color-border);
|
|
10
|
+
box-shadow: var(--shadow-lg);
|
|
11
|
+
font-size: 13px;
|
|
12
|
+
font-weight: 500;
|
|
13
|
+
animation: slideIn 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
14
|
+
min-width: 280px;
|
|
15
|
+
max-width: 420px;
|
|
16
|
+
}
|
|
17
|
+
.success { border-left: 4px solid #00ff88; }
|
|
18
|
+
.error { border-left: 4px solid #ff4444; }
|
|
19
|
+
.warning { border-left: 4px solid #ffaa00; }
|
|
20
|
+
.info { border-left: 4px solid #06b6d4; }
|
|
21
|
+
.icon { font-size: 18px; flex-shrink: 0; }
|
|
22
|
+
.message { flex: 1; }
|
|
23
|
+
.close {
|
|
24
|
+
background: none;
|
|
25
|
+
border: none;
|
|
26
|
+
color: var(--color-text-dim);
|
|
27
|
+
cursor: pointer;
|
|
28
|
+
font-size: 16px;
|
|
29
|
+
padding: 0;
|
|
30
|
+
line-height: 1;
|
|
31
|
+
}
|
|
32
|
+
.exiting { animation: slideOut 300ms cubic-bezier(0.16, 1, 0.3, 1) forwards; }
|
|
33
|
+
|
|
34
|
+
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
|
35
|
+
@keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/ToastContainer.jsx
|
|
3
|
+
* @description Fixed-position container that renders all active toasts.
|
|
4
|
+
*/
|
|
5
|
+
import styles from './ToastContainer.module.css'
|
|
6
|
+
import Toast from './Toast.jsx'
|
|
7
|
+
|
|
8
|
+
export default function ToastContainer({ toasts, dismissToast }) {
|
|
9
|
+
return (
|
|
10
|
+
<div className={styles.container}>
|
|
11
|
+
{toasts.map((t) => (
|
|
12
|
+
<Toast key={t.id} message={t.message} type={t.type} onDismiss={() => dismissToast(t.id)} />
|
|
13
|
+
))}
|
|
14
|
+
</div>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/VerdictBadge.jsx
|
|
3
|
+
* @description Renders a verdict badge (Perfect, Normal, Slow, etc.) with styled pill.
|
|
4
|
+
*/
|
|
5
|
+
import { verdictCls } from '../../utils/ranks.js'
|
|
6
|
+
import styles from './VerdictBadge.module.css'
|
|
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>
|
|
13
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
.badge {
|
|
2
|
+
display: inline-block;
|
|
3
|
+
font-size: 10px;
|
|
4
|
+
font-weight: 700;
|
|
5
|
+
text-transform: uppercase;
|
|
6
|
+
padding: 2px 8px;
|
|
7
|
+
border-radius: 999px;
|
|
8
|
+
letter-spacing: 0.5px;
|
|
9
|
+
}
|
|
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); }
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/dashboard/DetailPanel.jsx
|
|
3
|
+
* @description Slide-in detail panel showing full model stats, metrics, and a latency chart.
|
|
4
|
+
* 📖 Rendered as a fixed overlay on the right side. Controlled by `model` prop (null = hidden).
|
|
5
|
+
* Displays model ID, provider, tier, SWE score, context, status, pings, stability, verdict, uptime,
|
|
6
|
+
* ping count, API key status, and a larger SVG latency trend chart.
|
|
7
|
+
* @functions DetailPanel → main panel component, buildDetailChart → SVG chart builder
|
|
8
|
+
*/
|
|
9
|
+
import { useMemo } from 'react'
|
|
10
|
+
import TierBadge from '../atoms/TierBadge.jsx'
|
|
11
|
+
import VerdictBadge from '../atoms/VerdictBadge.jsx'
|
|
12
|
+
import StatusDot from '../atoms/StatusDot.jsx'
|
|
13
|
+
import StabilityCell from '../atoms/StabilityCell.jsx'
|
|
14
|
+
import { formatPing, formatAvg, pingClass } from '../../utils/format.js'
|
|
15
|
+
import { sweClass } from '../../utils/ranks.js'
|
|
16
|
+
import styles from './DetailPanel.module.css'
|
|
17
|
+
|
|
18
|
+
function buildDetailChart(history) {
|
|
19
|
+
if (!history || history.length < 2) {
|
|
20
|
+
return <div className={styles.chartEmpty}>Waiting for ping data...</div>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const valid = history.filter(p => p.code === '200' || p.code === '401')
|
|
24
|
+
if (valid.length < 2) {
|
|
25
|
+
return <div className={styles.chartEmpty}>Not enough data yet...</div>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const values = valid.map(p => p.ms)
|
|
29
|
+
const max = Math.max(...values, 1)
|
|
30
|
+
const min = Math.min(...values, 0)
|
|
31
|
+
const range = max - min || 1
|
|
32
|
+
const w = 340, h = 100
|
|
33
|
+
const padding = 4
|
|
34
|
+
|
|
35
|
+
const points = values.map((v, i) => {
|
|
36
|
+
const x = padding + i * ((w - 2 * padding) / (values.length - 1))
|
|
37
|
+
const y = padding + (h - 2 * padding) - ((v - min) / range) * (h - 2 * padding)
|
|
38
|
+
return [x.toFixed(1), y.toFixed(1)]
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const linePoints = points.map(p => p.join(',')).join(' ')
|
|
42
|
+
const areaPoints = `${points[0][0]},${h - padding} ${linePoints} ${points[points.length - 1][0]},${h - padding}`
|
|
43
|
+
const lastPt = points[points.length - 1]
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<svg width="100%" viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }}>
|
|
47
|
+
<defs>
|
|
48
|
+
<linearGradient id="chart-grad" x1="0" y1="0" x2="0" y2="1">
|
|
49
|
+
<stop offset="0%" stopColor="var(--color-accent)" stopOpacity="0.3" />
|
|
50
|
+
<stop offset="100%" stopColor="var(--color-accent)" stopOpacity="0.02" />
|
|
51
|
+
</linearGradient>
|
|
52
|
+
</defs>
|
|
53
|
+
<polygon fill="url(#chart-grad)" points={areaPoints} />
|
|
54
|
+
<polyline fill="none" stroke="var(--color-accent)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" points={linePoints} />
|
|
55
|
+
<circle cx={lastPt[0]} cy={lastPt[1]} r="3.5" fill="var(--color-accent)" stroke="var(--color-bg)" strokeWidth="1.5" />
|
|
56
|
+
<text x={padding} y={h - 2} fontSize="9" fill="var(--color-text-dim)" fontFamily="var(--font-mono)">{min}ms</text>
|
|
57
|
+
<text x={w - padding} y={padding + 8} fontSize="9" fill="var(--color-text-dim)" fontFamily="var(--font-mono)" textAnchor="end">{max}ms</text>
|
|
58
|
+
</svg>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function StatRow({ label, children }) {
|
|
63
|
+
return (
|
|
64
|
+
<div className={styles.stat}>
|
|
65
|
+
<span className={styles.statLabel}>{label}</span>
|
|
66
|
+
<span className={styles.statValue}>{children}</span>
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default function DetailPanel({ model, onClose }) {
|
|
72
|
+
if (!model) return null
|
|
73
|
+
|
|
74
|
+
const chartSvg = buildDetailChart(model.pingHistory)
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className={styles.panel}>
|
|
78
|
+
<div className={styles.header}>
|
|
79
|
+
<h3 className={styles.title}>{model.label}</h3>
|
|
80
|
+
<button className={styles.closeBtn} onClick={onClose}>×</button>
|
|
81
|
+
</div>
|
|
82
|
+
<div className={styles.body}>
|
|
83
|
+
<StatRow label="Model ID">
|
|
84
|
+
<span style={{ fontSize: 11, wordBreak: 'break-all' }}>{model.modelId}</span>
|
|
85
|
+
</StatRow>
|
|
86
|
+
<StatRow label="Provider">{model.origin}</StatRow>
|
|
87
|
+
<StatRow label="Tier"><TierBadge tier={model.tier} /></StatRow>
|
|
88
|
+
<StatRow label="SWE-bench Score">
|
|
89
|
+
<span className={`${styles.swe} ${styles[sweClass(model.sweScore)]}`}>{model.sweScore || '—'}</span>
|
|
90
|
+
</StatRow>
|
|
91
|
+
<StatRow label="Context Window">{model.ctx || '—'}</StatRow>
|
|
92
|
+
<StatRow label="Status">
|
|
93
|
+
<StatusDot status={model.status} /> {model.status}
|
|
94
|
+
</StatRow>
|
|
95
|
+
<StatRow label="Latest Ping">
|
|
96
|
+
<span className={`${styles.ping} ${styles[pingClass(model.latestPing)]}`}>
|
|
97
|
+
{formatPing(model.latestPing, model.latestCode).text}
|
|
98
|
+
</span>
|
|
99
|
+
</StatRow>
|
|
100
|
+
<StatRow label="Average Latency">
|
|
101
|
+
<span className={`${styles.ping} ${styles[pingClass(model.avg)]}`}>
|
|
102
|
+
{formatAvg(model.avg).text}
|
|
103
|
+
</span>
|
|
104
|
+
</StatRow>
|
|
105
|
+
<StatRow label="P95 Latency">
|
|
106
|
+
{model.p95 != null && model.p95 !== Infinity ? `${model.p95}ms` : '—'}
|
|
107
|
+
</StatRow>
|
|
108
|
+
<StatRow label="Jitter (σ)">
|
|
109
|
+
{model.jitter != null && model.jitter !== Infinity ? `${model.jitter}ms` : '—'}
|
|
110
|
+
</StatRow>
|
|
111
|
+
<StatRow label="Stability Score">
|
|
112
|
+
<StabilityCell score={model.stability} />
|
|
113
|
+
</StatRow>
|
|
114
|
+
<StatRow label="Verdict">
|
|
115
|
+
<VerdictBadge verdict={model.verdict} httpCode={model.httpCode} />
|
|
116
|
+
</StatRow>
|
|
117
|
+
<StatRow label="Uptime">
|
|
118
|
+
{model.uptime > 0 ? `${model.uptime}%` : '—'}
|
|
119
|
+
</StatRow>
|
|
120
|
+
<StatRow label="Ping Count">{model.pingCount}</StatRow>
|
|
121
|
+
<StatRow label="API Key">
|
|
122
|
+
{model.hasApiKey ? '✅ Configured' : '❌ Missing'}
|
|
123
|
+
</StatRow>
|
|
124
|
+
<div className={styles.chart}>
|
|
125
|
+
<div className={styles.chartTitle}>Latency Trend (last 20 pings)</div>
|
|
126
|
+
{chartSvg}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
.panel {
|
|
2
|
+
position: fixed;
|
|
3
|
+
right: 0;
|
|
4
|
+
top: 0;
|
|
5
|
+
bottom: 0;
|
|
6
|
+
width: min(420px, 90vw);
|
|
7
|
+
z-index: 300;
|
|
8
|
+
background: var(--color-bg-elevated);
|
|
9
|
+
border-left: 1px solid var(--color-border);
|
|
10
|
+
box-shadow: var(--shadow-lg);
|
|
11
|
+
transform: translateX(100%);
|
|
12
|
+
transition: transform var(--transition-medium);
|
|
13
|
+
overflow-y: auto;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.header {
|
|
17
|
+
display: flex;
|
|
18
|
+
align-items: center;
|
|
19
|
+
justify-content: space-between;
|
|
20
|
+
padding: 20px 24px;
|
|
21
|
+
border-bottom: 1px solid var(--color-border);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.title {
|
|
25
|
+
font-size: 16px;
|
|
26
|
+
font-weight: 700;
|
|
27
|
+
margin: 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.closeBtn {
|
|
31
|
+
background: none;
|
|
32
|
+
border: none;
|
|
33
|
+
color: var(--color-text-muted);
|
|
34
|
+
font-size: 22px;
|
|
35
|
+
cursor: pointer;
|
|
36
|
+
line-height: 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.closeBtn:hover {
|
|
40
|
+
color: var(--color-text);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.body {
|
|
44
|
+
padding: 24px;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.stat {
|
|
48
|
+
display: flex;
|
|
49
|
+
justify-content: space-between;
|
|
50
|
+
align-items: center;
|
|
51
|
+
padding: 10px 0;
|
|
52
|
+
border-bottom: 1px solid var(--color-border);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.statLabel {
|
|
56
|
+
font-size: 12px;
|
|
57
|
+
color: var(--color-text-muted);
|
|
58
|
+
text-transform: uppercase;
|
|
59
|
+
letter-spacing: 0.5px;
|
|
60
|
+
font-weight: 600;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.statValue {
|
|
64
|
+
font-family: var(--font-mono);
|
|
65
|
+
font-weight: 600;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.ping {
|
|
69
|
+
font-family: var(--font-mono);
|
|
70
|
+
font-weight: 600;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.pingFast { color: #00ff88; }
|
|
74
|
+
.pingMedium { color: #ffaa00; }
|
|
75
|
+
.pingSlow { color: #ff4444; }
|
|
76
|
+
.pingNone { color: var(--color-text-dim); }
|
|
77
|
+
|
|
78
|
+
.chart {
|
|
79
|
+
margin: 20px 0;
|
|
80
|
+
padding: 16px;
|
|
81
|
+
background: var(--color-surface);
|
|
82
|
+
border-radius: var(--radius-md);
|
|
83
|
+
border: 1px solid var(--color-border);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.chartTitle {
|
|
87
|
+
font-size: 11px;
|
|
88
|
+
font-weight: 700;
|
|
89
|
+
color: var(--color-text-muted);
|
|
90
|
+
text-transform: uppercase;
|
|
91
|
+
letter-spacing: 0.8px;
|
|
92
|
+
margin-bottom: 12px;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.chartEmpty {
|
|
96
|
+
color: var(--color-text-dim);
|
|
97
|
+
text-align: center;
|
|
98
|
+
padding: 20px;
|
|
99
|
+
}
|