free-coding-models 0.3.34 → 0.3.36
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 +30 -0
- package/README.md +57 -17
- package/bin/free-coding-models.js +8 -0
- package/package.json +3 -2
- package/sources.js +20 -0
- package/src/provider-metadata.js +8 -0
- package/src/render-table.js +1 -8
- package/src/utils.js +4 -0
- package/web/app.js +900 -0
- package/web/index.html +318 -0
- package/web/server.js +317 -0
- package/web/styles.css +963 -0
package/web/index.html
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" data-theme="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>free-coding-models — Live Dashboard</title>
|
|
7
|
+
<meta name="description" content="Find the fastest free coding LLM model in seconds. Live dashboard monitoring 230+ models across 24 providers.">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
11
|
+
<link rel="stylesheet" href="/styles.css">
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<!-- ─── Background Effects ─── -->
|
|
15
|
+
<div class="bg-grid"></div>
|
|
16
|
+
<div class="bg-glow bg-glow--1"></div>
|
|
17
|
+
<div class="bg-glow bg-glow--2"></div>
|
|
18
|
+
<div class="bg-glow bg-glow--3"></div>
|
|
19
|
+
|
|
20
|
+
<!-- ─── Toast Container ─── -->
|
|
21
|
+
<div class="toast-container" id="toast-container"></div>
|
|
22
|
+
|
|
23
|
+
<!-- ─── Sidebar ─── -->
|
|
24
|
+
<aside class="sidebar" id="sidebar">
|
|
25
|
+
<div class="sidebar__logo">
|
|
26
|
+
<span class="sidebar__logo-icon">⚡</span>
|
|
27
|
+
<span class="sidebar__logo-text">FCM</span>
|
|
28
|
+
</div>
|
|
29
|
+
<nav class="sidebar__nav">
|
|
30
|
+
<button class="sidebar__nav-item sidebar__nav-item--active" data-view="dashboard" id="nav-dashboard" title="Dashboard">
|
|
31
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
|
32
|
+
<span class="sidebar__nav-label">Dashboard</span>
|
|
33
|
+
</button>
|
|
34
|
+
<button class="sidebar__nav-item" data-view="settings" id="nav-settings" title="Settings">
|
|
35
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
|
36
|
+
<span class="sidebar__nav-label">Settings</span>
|
|
37
|
+
</button>
|
|
38
|
+
<button class="sidebar__nav-item" data-view="analytics" id="nav-analytics" title="Analytics">
|
|
39
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
|
40
|
+
<span class="sidebar__nav-label">Analytics</span>
|
|
41
|
+
</button>
|
|
42
|
+
</nav>
|
|
43
|
+
<div class="sidebar__bottom">
|
|
44
|
+
<button class="sidebar__nav-item" id="sidebar-theme-toggle" title="Toggle Theme">
|
|
45
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
|
46
|
+
<span class="sidebar__nav-label">Theme</span>
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
</aside>
|
|
50
|
+
|
|
51
|
+
<!-- ─── Main Content ─── -->
|
|
52
|
+
<div class="app-content" id="app-content">
|
|
53
|
+
|
|
54
|
+
<!-- ═══════ DASHBOARD VIEW ═══════ -->
|
|
55
|
+
<div class="view" id="view-dashboard">
|
|
56
|
+
|
|
57
|
+
<!-- Header -->
|
|
58
|
+
<header class="header" id="header">
|
|
59
|
+
<div class="header__left">
|
|
60
|
+
<button class="btn btn--icon sidebar-toggle" id="sidebar-toggle" title="Toggle Sidebar">
|
|
61
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
|
62
|
+
</button>
|
|
63
|
+
<div class="logo">
|
|
64
|
+
<span class="logo__icon">⚡</span>
|
|
65
|
+
<span class="logo__text">free-coding-models</span>
|
|
66
|
+
</div>
|
|
67
|
+
<span class="header__version" id="version-badge">v0.3.35</span>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="header__center">
|
|
70
|
+
<div class="search-bar" id="search-bar">
|
|
71
|
+
<svg class="search-bar__icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
|
72
|
+
<input type="text" class="search-bar__input" id="search-input" placeholder="Search models, providers, tiers..." autocomplete="off">
|
|
73
|
+
<kbd class="search-bar__kbd">Ctrl+K</kbd>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
<div class="header__right">
|
|
77
|
+
<button class="btn btn--icon" id="theme-toggle" title="Toggle theme">
|
|
78
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
|
79
|
+
</button>
|
|
80
|
+
<button class="btn btn--icon" id="export-btn" title="Export Data">
|
|
81
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
82
|
+
</button>
|
|
83
|
+
<button class="btn btn--primary" id="settings-btn">
|
|
84
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
|
85
|
+
Settings
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
</header>
|
|
89
|
+
|
|
90
|
+
<!-- Stats Bar -->
|
|
91
|
+
<section class="stats-bar" id="stats-bar">
|
|
92
|
+
<div class="stat-card" id="stat-total">
|
|
93
|
+
<div class="stat-card__icon">📊</div>
|
|
94
|
+
<div class="stat-card__body">
|
|
95
|
+
<div class="stat-card__value" id="stat-total-value">0</div>
|
|
96
|
+
<div class="stat-card__label">Total Models</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="stat-card" id="stat-online">
|
|
100
|
+
<div class="stat-card__icon">🟢</div>
|
|
101
|
+
<div class="stat-card__body">
|
|
102
|
+
<div class="stat-card__value" id="stat-online-value">0</div>
|
|
103
|
+
<div class="stat-card__label">Online</div>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
<div class="stat-card" id="stat-avg">
|
|
107
|
+
<div class="stat-card__icon">⚡</div>
|
|
108
|
+
<div class="stat-card__body">
|
|
109
|
+
<div class="stat-card__value" id="stat-avg-value">—</div>
|
|
110
|
+
<div class="stat-card__label">Avg Latency</div>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
<div class="stat-card" id="stat-best">
|
|
114
|
+
<div class="stat-card__icon">🏆</div>
|
|
115
|
+
<div class="stat-card__body">
|
|
116
|
+
<div class="stat-card__value" id="stat-best-value">—</div>
|
|
117
|
+
<div class="stat-card__label">Fastest Model</div>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
<div class="stat-card" id="stat-providers">
|
|
121
|
+
<div class="stat-card__icon">🌐</div>
|
|
122
|
+
<div class="stat-card__body">
|
|
123
|
+
<div class="stat-card__value" id="stat-providers-value">0</div>
|
|
124
|
+
<div class="stat-card__label">Providers</div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</section>
|
|
128
|
+
|
|
129
|
+
<!-- Filters -->
|
|
130
|
+
<section class="filters" id="filters">
|
|
131
|
+
<div class="filters__group">
|
|
132
|
+
<label class="filter-label">Tier</label>
|
|
133
|
+
<div class="tier-filters" id="tier-filters">
|
|
134
|
+
<button class="tier-btn tier-btn--active" data-tier="all">All</button>
|
|
135
|
+
<button class="tier-btn" data-tier="S+">S+</button>
|
|
136
|
+
<button class="tier-btn" data-tier="S">S</button>
|
|
137
|
+
<button class="tier-btn" data-tier="A+">A+</button>
|
|
138
|
+
<button class="tier-btn" data-tier="A">A</button>
|
|
139
|
+
<button class="tier-btn" data-tier="A-">A-</button>
|
|
140
|
+
<button class="tier-btn" data-tier="B+">B+</button>
|
|
141
|
+
<button class="tier-btn" data-tier="B">B</button>
|
|
142
|
+
<button class="tier-btn" data-tier="C">C</button>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
<div class="filters__group">
|
|
146
|
+
<label class="filter-label">Status</label>
|
|
147
|
+
<div class="status-filters" id="status-filters">
|
|
148
|
+
<button class="status-btn status-btn--active" data-status="all">All</button>
|
|
149
|
+
<button class="status-btn" data-status="up">Online</button>
|
|
150
|
+
<button class="status-btn" data-status="down">Offline</button>
|
|
151
|
+
<button class="status-btn" data-status="pending">Pending</button>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
<div class="filters__group">
|
|
155
|
+
<label class="filter-label">Provider</label>
|
|
156
|
+
<select class="provider-select" id="provider-filter">
|
|
157
|
+
<option value="all">All Providers</option>
|
|
158
|
+
</select>
|
|
159
|
+
</div>
|
|
160
|
+
<div class="filters__spacer"></div>
|
|
161
|
+
<div class="filters__group">
|
|
162
|
+
<div class="live-indicator" id="live-indicator">
|
|
163
|
+
<span class="live-dot"></span>
|
|
164
|
+
<span class="live-text">LIVE</span>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
</section>
|
|
168
|
+
|
|
169
|
+
<!-- Main Table -->
|
|
170
|
+
<main class="main" id="main">
|
|
171
|
+
<div class="table-container" id="table-container">
|
|
172
|
+
<table class="models-table" id="models-table">
|
|
173
|
+
<thead>
|
|
174
|
+
<tr>
|
|
175
|
+
<th class="th--rank sortable" data-sort="idx">#</th>
|
|
176
|
+
<th class="th--tier sortable" data-sort="tier">Tier</th>
|
|
177
|
+
<th class="th--model sortable" data-sort="label">Model</th>
|
|
178
|
+
<th class="th--provider sortable" data-sort="origin">Provider</th>
|
|
179
|
+
<th class="th--swe sortable" data-sort="sweScore">SWE %</th>
|
|
180
|
+
<th class="th--ctx sortable" data-sort="ctx">Context</th>
|
|
181
|
+
<th class="th--ping sortable" data-sort="latestPing">Ping</th>
|
|
182
|
+
<th class="th--avg sortable" data-sort="avg">Avg</th>
|
|
183
|
+
<th class="th--stability sortable" data-sort="stability">Stability</th>
|
|
184
|
+
<th class="th--verdict sortable" data-sort="verdict">Verdict</th>
|
|
185
|
+
<th class="th--uptime sortable" data-sort="uptime">Uptime</th>
|
|
186
|
+
<th class="th--sparkline">Trend</th>
|
|
187
|
+
</tr>
|
|
188
|
+
</thead>
|
|
189
|
+
<tbody id="table-body">
|
|
190
|
+
<tr class="loading-row">
|
|
191
|
+
<td colspan="12">
|
|
192
|
+
<div class="loading-spinner">
|
|
193
|
+
<div class="spinner"></div>
|
|
194
|
+
<span>Connecting to ping engine...</span>
|
|
195
|
+
</div>
|
|
196
|
+
</td>
|
|
197
|
+
</tr>
|
|
198
|
+
</tbody>
|
|
199
|
+
</table>
|
|
200
|
+
</div>
|
|
201
|
+
</main>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<!-- ═══════ SETTINGS VIEW ═══════ -->
|
|
205
|
+
<div class="view view--hidden" id="view-settings">
|
|
206
|
+
<div class="settings-page">
|
|
207
|
+
<div class="settings-page__header">
|
|
208
|
+
<h1 class="settings-page__title">⚙️ Provider Settings</h1>
|
|
209
|
+
<p class="settings-page__subtitle">Manage your API keys and provider configurations. Keys are stored locally in <code>~/.free-coding-models.json</code></p>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<!-- Settings Toolbar -->
|
|
213
|
+
<div class="settings-toolbar" id="settings-toolbar">
|
|
214
|
+
<div class="settings-toolbar__search">
|
|
215
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
|
216
|
+
<input type="text" id="settings-search" placeholder="Search providers..." autocomplete="off">
|
|
217
|
+
</div>
|
|
218
|
+
<div class="settings-toolbar__actions">
|
|
219
|
+
<button class="btn" id="settings-expand-all">Expand All</button>
|
|
220
|
+
<button class="btn" id="settings-collapse-all">Collapse All</button>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<!-- Provider Cards Container -->
|
|
225
|
+
<div class="settings-providers" id="settings-providers">
|
|
226
|
+
<!-- Populated by JS -->
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<!-- ═══════ ANALYTICS VIEW ═══════ -->
|
|
232
|
+
<div class="view view--hidden" id="view-analytics">
|
|
233
|
+
<div class="analytics-page">
|
|
234
|
+
<div class="analytics-page__header">
|
|
235
|
+
<h1 class="analytics-page__title">📊 Analytics</h1>
|
|
236
|
+
<p class="analytics-page__subtitle">Real-time insights across all providers and models</p>
|
|
237
|
+
</div>
|
|
238
|
+
<div class="analytics-grid" id="analytics-grid">
|
|
239
|
+
<!-- Provider Health Overview -->
|
|
240
|
+
<div class="analytics-card analytics-card--wide" id="analytics-provider-health">
|
|
241
|
+
<h3 class="analytics-card__title">Provider Health Overview</h3>
|
|
242
|
+
<div class="analytics-card__body" id="provider-health-body">
|
|
243
|
+
<!-- Populated by JS -->
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
<!-- Top 10 Fastest -->
|
|
247
|
+
<div class="analytics-card" id="analytics-leaderboard">
|
|
248
|
+
<h3 class="analytics-card__title">🏆 Fastest Models</h3>
|
|
249
|
+
<div class="analytics-card__body" id="leaderboard-body">
|
|
250
|
+
<!-- Populated by JS -->
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
<!-- Tier Distribution -->
|
|
254
|
+
<div class="analytics-card" id="analytics-tier-dist">
|
|
255
|
+
<h3 class="analytics-card__title">Tier Distribution</h3>
|
|
256
|
+
<div class="analytics-card__body" id="tier-dist-body">
|
|
257
|
+
<!-- Populated by JS -->
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
<!-- ─── Footer ─── -->
|
|
265
|
+
<footer class="footer">
|
|
266
|
+
<div class="footer__left">
|
|
267
|
+
<span>Made with ❤️ by <a href="https://vavanessa.dev" target="_blank">Vava-Nessa</a></span>
|
|
268
|
+
</div>
|
|
269
|
+
<div class="footer__right">
|
|
270
|
+
<a href="https://github.com/vava-nessa/free-coding-models" target="_blank">GitHub</a>
|
|
271
|
+
<a href="https://discord.gg/ZTNFHvvCkU" target="_blank">Discord</a>
|
|
272
|
+
</div>
|
|
273
|
+
</footer>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<!-- ─── Model Detail Panel ─── -->
|
|
277
|
+
<div class="detail-panel" id="detail-panel" hidden>
|
|
278
|
+
<div class="detail-panel__header">
|
|
279
|
+
<h3 class="detail-panel__title" id="detail-title">Model Details</h3>
|
|
280
|
+
<button class="detail-panel__close" id="detail-close">×</button>
|
|
281
|
+
</div>
|
|
282
|
+
<div class="detail-panel__body" id="detail-body">
|
|
283
|
+
<!-- Populated by JS -->
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<!-- ─── Export Modal ─── -->
|
|
288
|
+
<div class="modal-overlay" id="export-modal" hidden>
|
|
289
|
+
<div class="modal">
|
|
290
|
+
<div class="modal__header">
|
|
291
|
+
<h2 class="modal__title">📤 Export Data</h2>
|
|
292
|
+
<button class="modal__close" id="export-close">×</button>
|
|
293
|
+
</div>
|
|
294
|
+
<div class="modal__body">
|
|
295
|
+
<div class="export-options">
|
|
296
|
+
<button class="export-option" id="export-json">
|
|
297
|
+
<span class="export-option__icon">{ }</span>
|
|
298
|
+
<span class="export-option__label">Export as JSON</span>
|
|
299
|
+
<span class="export-option__desc">Full model data with all metrics</span>
|
|
300
|
+
</button>
|
|
301
|
+
<button class="export-option" id="export-csv">
|
|
302
|
+
<span class="export-option__icon">📊</span>
|
|
303
|
+
<span class="export-option__label">Export as CSV</span>
|
|
304
|
+
<span class="export-option__desc">Spreadsheet-compatible format</span>
|
|
305
|
+
</button>
|
|
306
|
+
<button class="export-option" id="export-clipboard">
|
|
307
|
+
<span class="export-option__icon">📋</span>
|
|
308
|
+
<span class="export-option__label">Copy to Clipboard</span>
|
|
309
|
+
<span class="export-option__desc">Copy model summary as text</span>
|
|
310
|
+
</button>
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
<script src="/app.js" type="module"></script>
|
|
317
|
+
</body>
|
|
318
|
+
</html>
|
package/web/server.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/server.js
|
|
3
|
+
* @description HTTP server for the free-coding-models Web Dashboard V2.
|
|
4
|
+
*
|
|
5
|
+
* Reuses the existing ping engine, model sources, and utility functions
|
|
6
|
+
* from the CLI tool. Serves the dashboard HTML/CSS/JS and provides
|
|
7
|
+
* API endpoints + SSE for real-time ping data.
|
|
8
|
+
*
|
|
9
|
+
* Endpoints:
|
|
10
|
+
* GET / → Dashboard HTML
|
|
11
|
+
* GET /styles.css → Dashboard styles
|
|
12
|
+
* GET /app.js → Dashboard client JS
|
|
13
|
+
* GET /api/models → All model metadata (JSON)
|
|
14
|
+
* GET /api/config → Current config (sanitized — masked keys)
|
|
15
|
+
* GET /api/key/:prov → Reveal a provider's full API key
|
|
16
|
+
* GET /api/events → SSE stream of live ping results
|
|
17
|
+
* POST /api/settings → Update API keys / provider toggles
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createServer } from 'node:http'
|
|
21
|
+
import { readFileSync } from 'node:fs'
|
|
22
|
+
import { join, dirname } from 'node:path'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
24
|
+
|
|
25
|
+
import { sources, MODELS } from '../sources.js'
|
|
26
|
+
import { loadConfig, getApiKey, saveConfig, isProviderEnabled } from '../src/config.js'
|
|
27
|
+
import { ping } from '../src/ping.js'
|
|
28
|
+
import {
|
|
29
|
+
getAvg, getVerdict, getUptime, getP95, getJitter,
|
|
30
|
+
getStabilityScore, TIER_ORDER
|
|
31
|
+
} from '../src/utils.js'
|
|
32
|
+
|
|
33
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
34
|
+
|
|
35
|
+
// ─── State ───────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
let config = loadConfig()
|
|
38
|
+
|
|
39
|
+
// Build results array from MODELS (same shape as the TUI)
|
|
40
|
+
const results = MODELS.map(([modelId, label, tier, sweScore, ctx, providerKey], idx) => ({
|
|
41
|
+
idx: idx + 1,
|
|
42
|
+
modelId,
|
|
43
|
+
label,
|
|
44
|
+
tier,
|
|
45
|
+
sweScore,
|
|
46
|
+
ctx,
|
|
47
|
+
providerKey,
|
|
48
|
+
status: 'pending',
|
|
49
|
+
pings: [],
|
|
50
|
+
httpCode: null,
|
|
51
|
+
origin: sources[providerKey]?.name || providerKey,
|
|
52
|
+
url: sources[providerKey]?.url || null,
|
|
53
|
+
cliOnly: sources[providerKey]?.cliOnly || false,
|
|
54
|
+
zenOnly: sources[providerKey]?.zenOnly || false,
|
|
55
|
+
}))
|
|
56
|
+
|
|
57
|
+
// SSE clients
|
|
58
|
+
const sseClients = new Set()
|
|
59
|
+
|
|
60
|
+
// ─── Ping Loop ───────────────────────────────────────────────────────────────
|
|
61
|
+
// Uses recursive setTimeout (not setInterval) to prevent overlapping rounds.
|
|
62
|
+
// Each new round starts only after the previous one completes.
|
|
63
|
+
|
|
64
|
+
let pingRound = 0
|
|
65
|
+
let pingLoopRunning = false
|
|
66
|
+
|
|
67
|
+
async function pingAllModels() {
|
|
68
|
+
if (pingLoopRunning) return // guard against overlapping calls
|
|
69
|
+
pingLoopRunning = true
|
|
70
|
+
pingRound++
|
|
71
|
+
const batchSize = 30
|
|
72
|
+
// P2 fix: honor provider enabled flags — skip disabled providers
|
|
73
|
+
const modelsToPing = results.filter(r =>
|
|
74
|
+
!r.cliOnly && r.url && isProviderEnabled(config, r.providerKey)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < modelsToPing.length; i += batchSize) {
|
|
78
|
+
const batch = modelsToPing.slice(i, i + batchSize)
|
|
79
|
+
const promises = batch.map(async (r) => {
|
|
80
|
+
const apiKey = getApiKey(config, r.providerKey)
|
|
81
|
+
try {
|
|
82
|
+
const result = await ping(apiKey, r.modelId, r.providerKey, r.url)
|
|
83
|
+
r.httpCode = result.code
|
|
84
|
+
if (result.code === '200') {
|
|
85
|
+
r.status = 'up'
|
|
86
|
+
r.pings.push({ ms: result.ms, code: result.code })
|
|
87
|
+
} else if (result.code === '401') {
|
|
88
|
+
r.status = 'up'
|
|
89
|
+
r.pings.push({ ms: result.ms, code: result.code })
|
|
90
|
+
} else if (result.code === '429') {
|
|
91
|
+
r.status = 'up'
|
|
92
|
+
r.pings.push({ ms: result.ms, code: result.code })
|
|
93
|
+
} else if (result.code === '000') {
|
|
94
|
+
r.status = 'timeout'
|
|
95
|
+
} else {
|
|
96
|
+
r.status = 'down'
|
|
97
|
+
r.pings.push({ ms: result.ms, code: result.code })
|
|
98
|
+
}
|
|
99
|
+
// Keep only last 60 pings
|
|
100
|
+
if (r.pings.length > 60) r.pings = r.pings.slice(-60)
|
|
101
|
+
} catch {
|
|
102
|
+
r.status = 'timeout'
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
await Promise.all(promises)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Broadcast update to all SSE clients
|
|
109
|
+
broadcastUpdate()
|
|
110
|
+
pingLoopRunning = false
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function broadcastUpdate() {
|
|
114
|
+
const data = JSON.stringify(getModelsPayload())
|
|
115
|
+
for (const client of sseClients) {
|
|
116
|
+
try {
|
|
117
|
+
client.write(`data: ${data}\n\n`)
|
|
118
|
+
} catch {
|
|
119
|
+
sseClients.delete(client)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getModelsPayload() {
|
|
125
|
+
return results.map(r => ({
|
|
126
|
+
idx: r.idx,
|
|
127
|
+
modelId: r.modelId,
|
|
128
|
+
label: r.label,
|
|
129
|
+
tier: r.tier,
|
|
130
|
+
sweScore: r.sweScore,
|
|
131
|
+
ctx: r.ctx,
|
|
132
|
+
providerKey: r.providerKey,
|
|
133
|
+
origin: r.origin,
|
|
134
|
+
status: r.status,
|
|
135
|
+
httpCode: r.httpCode,
|
|
136
|
+
cliOnly: r.cliOnly,
|
|
137
|
+
zenOnly: r.zenOnly,
|
|
138
|
+
avg: getAvg(r),
|
|
139
|
+
verdict: getVerdict(r),
|
|
140
|
+
uptime: getUptime(r),
|
|
141
|
+
p95: getP95(r),
|
|
142
|
+
jitter: getJitter(r),
|
|
143
|
+
stability: getStabilityScore(r),
|
|
144
|
+
latestPing: r.pings.length > 0 ? r.pings[r.pings.length - 1].ms : null,
|
|
145
|
+
latestCode: r.pings.length > 0 ? r.pings[r.pings.length - 1].code : null,
|
|
146
|
+
pingHistory: r.pings.slice(-20).map(p => ({ ms: p.ms, code: p.code })),
|
|
147
|
+
pingCount: r.pings.length,
|
|
148
|
+
hasApiKey: !!getApiKey(config, r.providerKey),
|
|
149
|
+
}))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function getConfigPayload() {
|
|
153
|
+
// Sanitize — show which providers have keys, but not the actual keys
|
|
154
|
+
const providers = {}
|
|
155
|
+
for (const [key, src] of Object.entries(sources)) {
|
|
156
|
+
const rawKey = getApiKey(config, key)
|
|
157
|
+
providers[key] = {
|
|
158
|
+
name: src.name,
|
|
159
|
+
hasKey: !!rawKey,
|
|
160
|
+
maskedKey: rawKey ? maskApiKey(rawKey) : null,
|
|
161
|
+
enabled: isProviderEnabled(config, key),
|
|
162
|
+
modelCount: src.models?.length || 0,
|
|
163
|
+
cliOnly: src.cliOnly || false,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { providers, totalModels: MODELS.length }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function maskApiKey(key) {
|
|
170
|
+
if (!key || typeof key !== 'string') return ''
|
|
171
|
+
if (key.length <= 8) return '••••••••'
|
|
172
|
+
return '••••••••' + key.slice(-4)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── HTTP Server ─────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
function serveFile(res, filename, contentType) {
|
|
178
|
+
try {
|
|
179
|
+
const content = readFileSync(join(__dirname, filename), 'utf8')
|
|
180
|
+
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' })
|
|
181
|
+
res.end(content)
|
|
182
|
+
} catch {
|
|
183
|
+
res.writeHead(404)
|
|
184
|
+
res.end('Not Found')
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function handleRequest(req, res) {
|
|
189
|
+
// CORS for local dev
|
|
190
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
191
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
192
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
193
|
+
|
|
194
|
+
if (req.method === 'OPTIONS') {
|
|
195
|
+
res.writeHead(204)
|
|
196
|
+
res.end()
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
201
|
+
|
|
202
|
+
// ─── API: Reveal full key for a provider ───
|
|
203
|
+
const keyMatch = url.pathname.match(/^\/api\/key\/(.+)$/)
|
|
204
|
+
if (keyMatch) {
|
|
205
|
+
const providerKey = decodeURIComponent(keyMatch[1])
|
|
206
|
+
const rawKey = getApiKey(config, providerKey)
|
|
207
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
208
|
+
res.end(JSON.stringify({ key: rawKey || null }))
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
switch (url.pathname) {
|
|
213
|
+
case '/':
|
|
214
|
+
serveFile(res, 'index.html', 'text/html; charset=utf-8')
|
|
215
|
+
break
|
|
216
|
+
|
|
217
|
+
case '/styles.css':
|
|
218
|
+
serveFile(res, 'styles.css', 'text/css; charset=utf-8')
|
|
219
|
+
break
|
|
220
|
+
|
|
221
|
+
case '/app.js':
|
|
222
|
+
serveFile(res, 'app.js', 'application/javascript; charset=utf-8')
|
|
223
|
+
break
|
|
224
|
+
|
|
225
|
+
case '/api/models':
|
|
226
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
227
|
+
res.end(JSON.stringify(getModelsPayload()))
|
|
228
|
+
break
|
|
229
|
+
|
|
230
|
+
case '/api/config':
|
|
231
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
232
|
+
res.end(JSON.stringify(getConfigPayload()))
|
|
233
|
+
break
|
|
234
|
+
|
|
235
|
+
case '/api/events':
|
|
236
|
+
// SSE endpoint
|
|
237
|
+
res.writeHead(200, {
|
|
238
|
+
'Content-Type': 'text/event-stream',
|
|
239
|
+
'Cache-Control': 'no-cache',
|
|
240
|
+
'Connection': 'keep-alive',
|
|
241
|
+
})
|
|
242
|
+
res.write(`data: ${JSON.stringify(getModelsPayload())}\n\n`)
|
|
243
|
+
sseClients.add(res)
|
|
244
|
+
req.on('close', () => sseClients.delete(res))
|
|
245
|
+
break
|
|
246
|
+
|
|
247
|
+
case '/api/settings':
|
|
248
|
+
if (req.method === 'POST') {
|
|
249
|
+
let body = ''
|
|
250
|
+
req.on('data', chunk => body += chunk)
|
|
251
|
+
req.on('end', () => {
|
|
252
|
+
try {
|
|
253
|
+
const settings = JSON.parse(body)
|
|
254
|
+
if (settings.apiKeys) {
|
|
255
|
+
for (const [key, value] of Object.entries(settings.apiKeys)) {
|
|
256
|
+
if (value) config.apiKeys[key] = value
|
|
257
|
+
else delete config.apiKeys[key]
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (settings.providers) {
|
|
261
|
+
for (const [key, value] of Object.entries(settings.providers)) {
|
|
262
|
+
if (!config.providers[key]) config.providers[key] = {}
|
|
263
|
+
config.providers[key].enabled = value.enabled !== false
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// P2 fix: catch saveConfig failures and report to client
|
|
267
|
+
try {
|
|
268
|
+
saveConfig(config)
|
|
269
|
+
} catch (saveErr) {
|
|
270
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
271
|
+
res.end(JSON.stringify({ success: false, error: 'Failed to save config: ' + saveErr.message }))
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
275
|
+
res.end(JSON.stringify({ success: true }))
|
|
276
|
+
} catch (err) {
|
|
277
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
278
|
+
res.end(JSON.stringify({ error: err.message }))
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
} else {
|
|
282
|
+
res.writeHead(405)
|
|
283
|
+
res.end('Method Not Allowed')
|
|
284
|
+
}
|
|
285
|
+
break
|
|
286
|
+
|
|
287
|
+
default:
|
|
288
|
+
res.writeHead(404)
|
|
289
|
+
res.end('Not Found')
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
export async function startWebServer(port = 3333) {
|
|
296
|
+
const server = createServer(handleRequest)
|
|
297
|
+
|
|
298
|
+
server.listen(port, () => {
|
|
299
|
+
console.log()
|
|
300
|
+
console.log(` ⚡ free-coding-models Web Dashboard`)
|
|
301
|
+
console.log(` 🌐 http://localhost:${port}`)
|
|
302
|
+
console.log(` 📊 Monitoring ${results.filter(r => !r.cliOnly).length} models across ${Object.keys(sources).length} providers`)
|
|
303
|
+
console.log()
|
|
304
|
+
console.log(` Press Ctrl+C to stop`)
|
|
305
|
+
console.log()
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
// P1 fix: serialize ping rounds — each round starts only after the
|
|
309
|
+
// previous one finishes, preventing overlapping concurrent mutations.
|
|
310
|
+
async function schedulePingLoop() {
|
|
311
|
+
await pingAllModels()
|
|
312
|
+
setTimeout(schedulePingLoop, 10_000)
|
|
313
|
+
}
|
|
314
|
+
schedulePingLoop()
|
|
315
|
+
|
|
316
|
+
return server
|
|
317
|
+
}
|