free-coding-models 0.3.37 → 0.3.41
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 +5 -1800
- package/README.md +10 -1
- package/bin/free-coding-models.js +8 -0
- package/package.json +13 -3
- package/src/app.js +30 -0
- package/src/cli-help.js +2 -0
- package/src/command-palette.js +3 -0
- package/src/config.js +7 -0
- package/src/endpoint-installer.js +1 -1
- package/src/key-handler.js +27 -1
- package/src/overlays.js +11 -1
- package/src/shell-env.js +393 -0
- package/src/tool-bootstrap.js +41 -0
- package/src/tool-launchers.js +166 -1
- package/src/tool-metadata.js +12 -0
- package/src/utils.js +12 -0
- package/web/app.legacy.js +900 -0
- package/web/index.html +20 -0
- package/web/server.js +443 -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,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
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/dashboard/ExportModal.jsx
|
|
3
|
+
* @description Modal dialog for exporting model data as JSON, CSV, or clipboard text.
|
|
4
|
+
* 📖 Triggered by the export button in the header. Accepts `models` (filtered list),
|
|
5
|
+
* `onClose` callback, and `onToast` for feedback notifications.
|
|
6
|
+
* @functions ExportModal → renders the modal with three export option buttons
|
|
7
|
+
*/
|
|
8
|
+
import { downloadFile } from '../../utils/download.js'
|
|
9
|
+
import styles from './ExportModal.module.css'
|
|
10
|
+
|
|
11
|
+
export default function ExportModal({ models, onClose, onToast }) {
|
|
12
|
+
if (!models) return null
|
|
13
|
+
|
|
14
|
+
const handleJson = () => {
|
|
15
|
+
const data = JSON.stringify(models, null, 2)
|
|
16
|
+
downloadFile(data, 'free-coding-models-export.json', 'application/json')
|
|
17
|
+
onToast?.('Exported as JSON', 'success')
|
|
18
|
+
onClose()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const handleCsv = () => {
|
|
22
|
+
const headers = ['Rank', 'Tier', 'Model', 'Provider', 'SWE%', 'Context', 'LatestPing', 'AvgPing', 'Stability', 'Verdict', 'Uptime']
|
|
23
|
+
const rows = models.map((m, i) =>
|
|
24
|
+
[i + 1, m.tier, m.label, m.origin, m.sweScore || '', m.ctx || '', m.latestPing || '', m.avg === Infinity ? '' : m.avg, m.stability || '', m.verdict || '', m.uptime || ''].join(',')
|
|
25
|
+
)
|
|
26
|
+
const csv = [headers.join(','), ...rows].join('\n')
|
|
27
|
+
downloadFile(csv, 'free-coding-models-export.csv', 'text/csv')
|
|
28
|
+
onToast?.('Exported as CSV', 'success')
|
|
29
|
+
onClose()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const handleClipboard = async () => {
|
|
33
|
+
const online = models.filter(m => m.status === 'up')
|
|
34
|
+
const text = `free-coding-models Dashboard Export\n` +
|
|
35
|
+
`Total: ${models.length} | Online: ${online.length}\n\n` +
|
|
36
|
+
online.slice(0, 20).map((m, i) =>
|
|
37
|
+
`${i + 1}. ${m.label} [${m.tier}] — ${m.avg !== Infinity ? m.avg + 'ms' : 'N/A'} (${m.origin})`
|
|
38
|
+
).join('\n')
|
|
39
|
+
await navigator.clipboard.writeText(text)
|
|
40
|
+
onToast?.('Copied to clipboard', 'success')
|
|
41
|
+
onClose()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className={styles.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
|
|
46
|
+
<div className={styles.modal}>
|
|
47
|
+
<div className={styles.modalHeader}>
|
|
48
|
+
<h2 className={styles.modalTitle}>📤 Export Data</h2>
|
|
49
|
+
<button className={styles.modalClose} onClick={onClose}>×</button>
|
|
50
|
+
</div>
|
|
51
|
+
<div className={styles.modalBody}>
|
|
52
|
+
<div className={styles.options}>
|
|
53
|
+
<button className={styles.option} onClick={handleJson}>
|
|
54
|
+
<span className={styles.optionIcon}>{'{ }'}</span>
|
|
55
|
+
<div>
|
|
56
|
+
<span className={styles.optionLabel}>Export as JSON</span>
|
|
57
|
+
<span className={styles.optionDesc}>Full model data with all metrics</span>
|
|
58
|
+
</div>
|
|
59
|
+
</button>
|
|
60
|
+
<button className={styles.option} onClick={handleCsv}>
|
|
61
|
+
<span className={styles.optionIcon}>📊</span>
|
|
62
|
+
<div>
|
|
63
|
+
<span className={styles.optionLabel}>Export as CSV</span>
|
|
64
|
+
<span className={styles.optionDesc}>Spreadsheet-compatible format</span>
|
|
65
|
+
</div>
|
|
66
|
+
</button>
|
|
67
|
+
<button className={styles.option} onClick={handleClipboard}>
|
|
68
|
+
<span className={styles.optionIcon}>📋</span>
|
|
69
|
+
<div>
|
|
70
|
+
<span className={styles.optionLabel}>Copy to Clipboard</span>
|
|
71
|
+
<span className={styles.optionDesc}>Copy model summary as text</span>
|
|
72
|
+
</div>
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
.overlay {
|
|
2
|
+
position: fixed;
|
|
3
|
+
inset: 0;
|
|
4
|
+
z-index: 1000;
|
|
5
|
+
background: rgba(0, 0, 0, 0.6);
|
|
6
|
+
backdrop-filter: blur(4px);
|
|
7
|
+
display: flex;
|
|
8
|
+
align-items: center;
|
|
9
|
+
justify-content: center;
|
|
10
|
+
animation: fadeIn 200ms ease-out;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.overlay[hidden] { display: none; }
|
|
14
|
+
|
|
15
|
+
@keyframes fadeIn {
|
|
16
|
+
from { opacity: 0; } to { opacity: 1; } }
|
|
17
|
+
|
|
18
|
+
.modal {
|
|
19
|
+
background: var(--color-bg-elevated);
|
|
20
|
+
border: 1px solid var(--color-border);
|
|
21
|
+
border-radius: var(--radius-xl);
|
|
22
|
+
width: min(500px, 90vw);
|
|
23
|
+
max-height: 80vh;
|
|
24
|
+
overflow-y: auto;
|
|
25
|
+
box-shadow: var(--shadow-lg);
|
|
26
|
+
animation: modalSlideIn 300ms var(--ease-out);
|
|
27
|
+
}
|
|
28
|
+
@keyframes modalSlideIn {
|
|
29
|
+
from { transform: translateY(20px) scale(0.96); opacity: 0; }
|
|
30
|
+
to { transform: translateY(0) scale(1); opacity: 1; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.modalHeader {
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: center;
|
|
36
|
+
justify-content: space-between;
|
|
37
|
+
padding: 20px 24px;
|
|
38
|
+
border-bottom: 1px solid var(--color-border);
|
|
39
|
+
}
|
|
40
|
+
.modalTitle {
|
|
41
|
+
font-size: 18px;
|
|
42
|
+
font-weight: 700;
|
|
43
|
+
margin: 0;
|
|
44
|
+
}
|
|
45
|
+
.modalClose {
|
|
46
|
+
background: none;
|
|
47
|
+
border: none;
|
|
48
|
+
color: var(--color-text-muted);
|
|
49
|
+
font-size: 24px;
|
|
50
|
+
cursor: pointer;
|
|
51
|
+
padding: 0;
|
|
52
|
+
line-height: 1;
|
|
53
|
+
}
|
|
54
|
+
.modalClose:hover {
|
|
55
|
+
color: var(--color-text);
|
|
56
|
+
}
|
|
57
|
+
.modalBody {
|
|
58
|
+
padding: 24px;
|
|
59
|
+
}
|
|
60
|
+
.options {
|
|
61
|
+
display: flex;
|
|
62
|
+
flex-direction: column;
|
|
63
|
+
gap: 10px;
|
|
64
|
+
}
|
|
65
|
+
.option {
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
gap: 14px;
|
|
69
|
+
padding: 14px 18px;
|
|
70
|
+
border: 1px solid var(--color-border);
|
|
71
|
+
border-radius: var(--radius-md);
|
|
72
|
+
background: var(--color-surface);
|
|
73
|
+
cursor: pointer;
|
|
74
|
+
transition: all var(--transition-fast);
|
|
75
|
+
text-align: left;
|
|
76
|
+
font-family: var(--font-sans);
|
|
77
|
+
color: var(--color-text);
|
|
78
|
+
width: 100%;
|
|
79
|
+
}
|
|
80
|
+
.option:hover {
|
|
81
|
+
border-color: var(--color-accent);
|
|
82
|
+
background: var(--color-accent-dim);
|
|
83
|
+
}
|
|
84
|
+
.optionIcon {
|
|
85
|
+
font-size: 24px;
|
|
86
|
+
flex-shrink: 0;
|
|
87
|
+
}
|
|
88
|
+
.optionText {}
|
|
89
|
+
.optionLabel {
|
|
90
|
+
display: block;
|
|
91
|
+
font-weight: 600;
|
|
92
|
+
font-size: 14px;
|
|
93
|
+
}
|
|
94
|
+
.optionDesc {
|
|
95
|
+
display: block;
|
|
96
|
+
font-size: 11px;
|
|
97
|
+
color: var(--color-text-muted);
|
|
98
|
+
margin-top: 2px;
|
|
99
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/dashboard/FilterBar.jsx
|
|
3
|
+
* @description Filter controls for tier, status, provider, and search + live indicator.
|
|
4
|
+
*/
|
|
5
|
+
import styles from './FilterBar.module.css'
|
|
6
|
+
|
|
7
|
+
const TIERS = ['All', 'S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
|
|
8
|
+
const STATUSES = [
|
|
9
|
+
{ key: 'all', label: 'All' },
|
|
10
|
+
{ key: 'up', label: 'Online' },
|
|
11
|
+
{ key: 'down', label: 'Offline' },
|
|
12
|
+
{ key: 'pending', label: 'Pending' },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
export default function FilterBar({
|
|
16
|
+
filterTier, setFilterTier,
|
|
17
|
+
filterStatus, setFilterStatus,
|
|
18
|
+
filterProvider, setFilterProvider,
|
|
19
|
+
providers,
|
|
20
|
+
}) {
|
|
21
|
+
return (
|
|
22
|
+
<section className={styles.filters}>
|
|
23
|
+
<div className={styles.group}>
|
|
24
|
+
<label className={styles.filterLabel}>Tier</label>
|
|
25
|
+
<div className={styles.tierRow}>
|
|
26
|
+
{TIERS.map(t => (
|
|
27
|
+
<button
|
|
28
|
+
key={t}
|
|
29
|
+
className={`${styles.tierBtn} ${filterTier === t ? styles.active : ''}`}
|
|
30
|
+
onClick={() => setFilterTier(t)}
|
|
31
|
+
>
|
|
32
|
+
{t}
|
|
33
|
+
</button>
|
|
34
|
+
))}
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
<div className={styles.group}>
|
|
38
|
+
<label className={styles.filterLabel}>Status</label>
|
|
39
|
+
<div className={styles.tierRow}>
|
|
40
|
+
{STATUSES.map(s => (
|
|
41
|
+
<button
|
|
42
|
+
key={s.key}
|
|
43
|
+
className={`${styles.tierBtn} ${filterStatus === s.key ? styles.active : ''}`}
|
|
44
|
+
onClick={() => setFilterStatus(s.key)}
|
|
45
|
+
>
|
|
46
|
+
{s.label}
|
|
47
|
+
</button>
|
|
48
|
+
))}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
<div className={styles.group}>
|
|
52
|
+
<label className={styles.filterLabel}>Provider</label>
|
|
53
|
+
<select
|
|
54
|
+
className={styles.providerSelect}
|
|
55
|
+
value={filterProvider}
|
|
56
|
+
onChange={e => setFilterProvider(e.target.value)}
|
|
57
|
+
>
|
|
58
|
+
<option value="all">All Providers</option>
|
|
59
|
+
{providers.map(p => (
|
|
60
|
+
<option key={p.key} value={p.key}>{p.name} ({p.count})</option>
|
|
61
|
+
))}
|
|
62
|
+
</select>
|
|
63
|
+
</div>
|
|
64
|
+
<div className={styles.spacer} />
|
|
65
|
+
<div className={styles.group}>
|
|
66
|
+
<div className={styles.live}>
|
|
67
|
+
<span className={styles.liveDot} />
|
|
68
|
+
<span>LIVE</span>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</section>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
.filters {
|
|
2
|
+
position: relative; z-index: 1;
|
|
3
|
+
display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
|
|
4
|
+
padding: 16px 24px;
|
|
5
|
+
}
|
|
6
|
+
.group { display: flex; align-items: center; gap: 8px; }
|
|
7
|
+
.filterLabel {
|
|
8
|
+
font-size: 11px; font-weight: 600; color: var(--color-text-muted);
|
|
9
|
+
text-transform: uppercase; letter-spacing: 0.5px;
|
|
10
|
+
}
|
|
11
|
+
.tierRow { display: flex; gap: 4px; }
|
|
12
|
+
.tierBtn {
|
|
13
|
+
font-size: 12px; font-weight: 600; font-family: var(--font-mono);
|
|
14
|
+
padding: 4px 10px; border-radius: 6px;
|
|
15
|
+
border: 1px solid var(--color-border); background: var(--color-surface);
|
|
16
|
+
color: var(--color-text-muted); cursor: pointer; transition: all 150ms;
|
|
17
|
+
}
|
|
18
|
+
.tierBtn:hover { background: var(--color-bg-hover); color: var(--color-text); }
|
|
19
|
+
.active {
|
|
20
|
+
background: var(--color-accent) !important; color: #000 !important;
|
|
21
|
+
border-color: var(--color-accent) !important; font-weight: 700;
|
|
22
|
+
}
|
|
23
|
+
.providerSelect {
|
|
24
|
+
background: var(--color-surface); color: var(--color-text);
|
|
25
|
+
border: 1px solid var(--color-border); border-radius: 6px;
|
|
26
|
+
padding: 5px 10px; font-size: 12px; font-family: var(--font-sans);
|
|
27
|
+
cursor: pointer; outline: none;
|
|
28
|
+
}
|
|
29
|
+
.providerSelect:focus { border-color: var(--color-accent); }
|
|
30
|
+
.spacer { flex: 1; }
|
|
31
|
+
.live {
|
|
32
|
+
display: flex; align-items: center; gap: 6px;
|
|
33
|
+
font-size: 11px; font-weight: 700; color: var(--color-accent);
|
|
34
|
+
text-transform: uppercase; letter-spacing: 1px;
|
|
35
|
+
}
|
|
36
|
+
.liveDot {
|
|
37
|
+
width: 8px; height: 8px; border-radius: 50%;
|
|
38
|
+
background: var(--color-accent); animation: pulseDot 2s ease-in-out infinite;
|
|
39
|
+
}
|
|
40
|
+
@keyframes pulseDot {
|
|
41
|
+
0%, 100% { box-shadow: 0 0 0 0 var(--color-accent-glow); }
|
|
42
|
+
50% { box-shadow: 0 0 0 6px transparent; }
|
|
43
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/dashboard/ModelTable.jsx
|
|
3
|
+
* @description Main data table with medal rankings for top 3 fastest models.
|
|
4
|
+
*/
|
|
5
|
+
import { useMemo } from 'react'
|
|
6
|
+
import TierBadge from '../atoms/TierBadge.jsx'
|
|
7
|
+
import StatusDot from '../atoms/StatusDot.jsx'
|
|
8
|
+
import VerdictBadge from '../atoms/VerdictBadge.jsx'
|
|
9
|
+
import StabilityCell from '../atoms/StabilityCell.jsx'
|
|
10
|
+
import Sparkline from '../atoms/Sparkline.jsx'
|
|
11
|
+
import { pingClass } from '../../utils/format.js'
|
|
12
|
+
import { sweClass } from '../../utils/ranks.js'
|
|
13
|
+
import styles from './ModelTable.module.css'
|
|
14
|
+
|
|
15
|
+
export default function ModelTable({ filtered, onSelectModel }) {
|
|
16
|
+
const top3Ids = useMemo(() => {
|
|
17
|
+
const online = filtered.filter(m => m.status === 'up' && m.avg !== Infinity)
|
|
18
|
+
return new Set([...online].sort((a, b) => a.avg - b.avg).slice(0, 3).map(m => m.modelId))
|
|
19
|
+
}, [filtered])
|
|
20
|
+
|
|
21
|
+
const top3Arr = useMemo(() => {
|
|
22
|
+
const online = filtered.filter(m => m.status === 'up' && m.avg !== Infinity)
|
|
23
|
+
return [...online].sort((a, b) => a.avg - b.avg).slice(0, 3).map(m => m.modelId)
|
|
24
|
+
}, [filtered])
|
|
25
|
+
|
|
26
|
+
if (filtered.length === 0) {
|
|
27
|
+
return <div className={styles.empty}>No models match your filters</div>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className={styles.container}>
|
|
32
|
+
<table className={styles.table}>
|
|
33
|
+
<thead>
|
|
34
|
+
<tr>
|
|
35
|
+
<th className={styles.th}>#</th>
|
|
36
|
+
<th className={styles.th}>Tier</th>
|
|
37
|
+
<th className={styles.th}>Model</th>
|
|
38
|
+
<th className={styles.th}>Provider</th>
|
|
39
|
+
<th className={styles.th}>SWE %</th>
|
|
40
|
+
<th className={styles.th}>Ctx</th>
|
|
41
|
+
<th className={styles.th}>Ping</th>
|
|
42
|
+
<th className={styles.th}>Avg</th>
|
|
43
|
+
<th className={styles.th}>Stability</th>
|
|
44
|
+
<th className={styles.th}>Verdict</th>
|
|
45
|
+
<th className={styles.th}>Uptime</th>
|
|
46
|
+
<th className={styles.th}>Trend</th>
|
|
47
|
+
</tr>
|
|
48
|
+
</thead>
|
|
49
|
+
<tbody>
|
|
50
|
+
{filtered.map((m, i) => {
|
|
51
|
+
const rankIdx = top3Arr.indexOf(m.modelId)
|
|
52
|
+
const medal = rankIdx === 0 ? '🥇' : rankIdx === 1 ? '🥈' : rankIdx === 2 ? '🥉' : ''
|
|
53
|
+
const rowCls = rankIdx >= 0 ? styles[`rank${rankIdx + 1}`] : ''
|
|
54
|
+
return (
|
|
55
|
+
<tr key={m.modelId} className={rowCls} onClick={() => onSelectModel(m)}>
|
|
56
|
+
<td className={styles.tdRank}>{medal || (i + 1)}</td>
|
|
57
|
+
<td><TierBadge tier={m.tier} /></td>
|
|
58
|
+
<td>
|
|
59
|
+
<div className={styles.modelCell}>
|
|
60
|
+
<StatusDot status={m.status} />
|
|
61
|
+
<span className={styles.modelName}>{m.label}</span>
|
|
62
|
+
{!m.hasApiKey && !m.cliOnly && <span className={styles.noKey}>🔑 NO KEY</span>}
|
|
63
|
+
<div className={styles.modelId}>{m.modelId}</div>
|
|
64
|
+
</div>
|
|
65
|
+
</td>
|
|
66
|
+
<td><span className={styles.providerPill}>{m.origin}</span></td>
|
|
67
|
+
<td className={`${styles.swe} ${styles[sweClass(m.sweScore)]}`}>{m.sweScore || '—'}</td>
|
|
68
|
+
<td className={styles.ctx}>{m.ctx || '—'}</td>
|
|
69
|
+
<td className={`${styles.ping} ${styles[pingClass(m.latestPing)]}`}>
|
|
70
|
+
{m.latestPing == null ? '—' : m.latestCode === '429' ? '429' : m.latestCode === '000' ? 'TIMEOUT' : `${m.latestPing}ms`}
|
|
71
|
+
</td>
|
|
72
|
+
<td className={`${styles.ping} ${styles[pingClass(m.avg)]}`}>
|
|
73
|
+
{m.avg == null || m.avg === Infinity || m.avg > 99000 ? '—' : `${m.avg}ms`}
|
|
74
|
+
</td>
|
|
75
|
+
<td><StabilityCell score={m.stability} /></td>
|
|
76
|
+
<td><VerdictBadge verdict={m.verdict} httpCode={m.httpCode} /></td>
|
|
77
|
+
<td className={styles.uptime}>{m.uptime > 0 ? `${m.uptime}%` : '—'}</td>
|
|
78
|
+
<td><Sparkline history={m.pingHistory} /></td>
|
|
79
|
+
</tr>
|
|
80
|
+
)
|
|
81
|
+
})}
|
|
82
|
+
</tbody>
|
|
83
|
+
</table>
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
.container {
|
|
2
|
+
background: var(--color-bg-card);
|
|
3
|
+
backdrop-filter: blur(12px) saturate(1.5);
|
|
4
|
+
-webkit-backdrop-filter: blur(12px) saturate(1.5);
|
|
5
|
+
border: 1px solid var(--color-border);
|
|
6
|
+
border-radius: 16px;
|
|
7
|
+
overflow: hidden;
|
|
8
|
+
margin: 16px 0;
|
|
9
|
+
}
|
|
10
|
+
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
11
|
+
.th {
|
|
12
|
+
padding: 12px 14px; font-size: 11px; font-weight: 700; text-transform: uppercase;
|
|
13
|
+
letter-spacing: 0.8px; color: var(--color-text-muted); background: var(--color-bg-elevated);
|
|
14
|
+
border-bottom: 1px solid var(--color-border); white-space: nowrap; user-select: none; cursor: default;
|
|
15
|
+
}
|
|
16
|
+
.th:hover { color: var(--color-accent); }
|
|
17
|
+
.table td { padding: 10px 14px; border-bottom: 1px solid var(--color-border); white-space: nowrap; }
|
|
18
|
+
.table tbody tr { cursor: pointer; transition: background 150ms; }
|
|
19
|
+
.table tbody tr:hover { background: var(--color-bg-hover); }
|
|
20
|
+
.tdRank { text-align: center; font-family: var(--font-mono); font-weight: 600; color: var(--color-text-dim); }
|
|
21
|
+
.rank1 td:first-child { border-left: 3px solid #ffd700; }
|
|
22
|
+
.rank2 td:first-child { border-left: 3px solid #c0c0c0; }
|
|
23
|
+
.rank3 td:first-child { border-left: 3px solid #cd7f32; }
|
|
24
|
+
.modelCell { display: flex; flex-direction: column; }
|
|
25
|
+
.modelName { font-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 4px; }
|
|
26
|
+
.modelId { font-family: var(--font-mono); font-size: 10px; color: var(--color-text-dim); margin-top: 1px; }
|
|
27
|
+
.noKey {
|
|
28
|
+
font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 3px;
|
|
29
|
+
background: rgba(255,170,0,0.12); color: #ffaa00; border: 1px solid rgba(255,170,0,0.2); margin-left: 6px;
|
|
30
|
+
}
|
|
31
|
+
.providerPill {
|
|
32
|
+
display: inline-block; font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: 999px;
|
|
33
|
+
background: var(--color-accent-dim); color: var(--color-text-muted); border: 1px solid var(--color-border);
|
|
34
|
+
}
|
|
35
|
+
.swe { font-family: var(--font-mono); font-weight: 600; }
|
|
36
|
+
.sweHigh { color: #ffd700; }
|
|
37
|
+
.sweMid { color: #3ddc84; }
|
|
38
|
+
.sweLow { color: var(--color-text-dim); }
|
|
39
|
+
.ctx { font-family: var(--font-mono); font-size: 12px; color: var(--color-text-muted); }
|
|
40
|
+
.ping { font-family: var(--font-mono); font-weight: 600; }
|
|
41
|
+
.pingFast { color: #00ff88; }
|
|
42
|
+
.pingMedium { color: #ffaa00; }
|
|
43
|
+
.pingSlow { color: #ff4444; }
|
|
44
|
+
.pingNone { color: var(--color-text-dim); }
|
|
45
|
+
.uptime { font-family: var(--font-mono); font-size: 12px; }
|
|
46
|
+
.empty { text-align: center; padding: 60px 0; color: var(--color-text-muted); }
|