cachegate 1.3.0 → 1.4.0
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/.env.example +83 -4
- package/LICENSE +21 -21
- package/README.md +154 -15
- package/cache.js +133 -9
- package/cascade.js +206 -0
- package/coalescing.js +65 -0
- package/embeddings.js +78 -16
- package/failover.js +76 -76
- package/guardrails.js +80 -0
- package/metrics.js +93 -7
- package/package.json +12 -2
- package/pii.js +128 -0
- package/providers/anthropic.js +122 -122
- package/providers/deepseek.js +194 -0
- package/providers/index.js +62 -0
- package/providers/openai.js +122 -114
- package/providers/openrouter.js +233 -0
- package/public/dashboard.html +1120 -1119
- package/router.js +33 -4
- package/semanticCache.js +115 -3
- package/server.js +494 -99
- package/streaming.js +77 -77
- package/tracing.js +97 -0
package/public/dashboard.html
CHANGED
|
@@ -1,1119 +1,1120 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="utf-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
-
<title>Model Router · Cost Dashboard</title>
|
|
7
|
-
<style>
|
|
8
|
-
:root {
|
|
9
|
-
color-scheme: light;
|
|
10
|
-
--page: #f9f9f7;
|
|
11
|
-
--surface-1: #fcfcfb;
|
|
12
|
-
--text-primary: #0b0b0b;
|
|
13
|
-
--text-secondary: #52514e;
|
|
14
|
-
--text-muted: #898781;
|
|
15
|
-
--grid: #e1e0d9;
|
|
16
|
-
--axis: #c3c2b7;
|
|
17
|
-
--border: rgba(11,11,11,0.10);
|
|
18
|
-
--series-1: #2a78d6; /* exact hit / cost line / provider bar 1 */
|
|
19
|
-
--series-2: #eb6834; /* semantic hit / provider bar 2 */
|
|
20
|
-
--series-3: #1baf7a; /* miss */
|
|
21
|
-
--status-critical: #d03b3b;
|
|
22
|
-
--status-good: #0ca30c;
|
|
23
|
-
--hover-wash: rgba(11,11,11,0.05);
|
|
24
|
-
}
|
|
25
|
-
@media (prefers-color-scheme: dark) {
|
|
26
|
-
:root {
|
|
27
|
-
color-scheme: dark;
|
|
28
|
-
--page: #0d0d0d;
|
|
29
|
-
--surface-1: #1a1a19;
|
|
30
|
-
--text-primary: #ffffff;
|
|
31
|
-
--text-secondary: #c3c2b7;
|
|
32
|
-
--text-muted: #898781;
|
|
33
|
-
--grid: #2c2c2a;
|
|
34
|
-
--axis: #383835;
|
|
35
|
-
--border: rgba(255,255,255,0.10);
|
|
36
|
-
--series-1: #3987e5;
|
|
37
|
-
--series-2: #d95926;
|
|
38
|
-
--series-3: #199e70;
|
|
39
|
-
--status-critical: #d03b3b;
|
|
40
|
-
--status-good: #0ca30c;
|
|
41
|
-
--hover-wash: rgba(255,255,255,0.06);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
* { box-sizing: border-box; }
|
|
46
|
-
html, body {
|
|
47
|
-
margin: 0;
|
|
48
|
-
background: var(--page);
|
|
49
|
-
color: var(--text-primary);
|
|
50
|
-
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
51
|
-
}
|
|
52
|
-
body { padding: 24px; max-width: 1100px; margin: 0 auto; }
|
|
53
|
-
h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
|
|
54
|
-
.subtitle { color: var(--text-secondary); font-size: 13px; margin: 0 0 20px; }
|
|
55
|
-
|
|
56
|
-
.card {
|
|
57
|
-
background: var(--surface-1);
|
|
58
|
-
border: 1px solid var(--border);
|
|
59
|
-
border-radius: 10px;
|
|
60
|
-
padding: 16px;
|
|
61
|
-
margin-bottom: 16px;
|
|
62
|
-
}
|
|
63
|
-
.card h2 {
|
|
64
|
-
font-size: 13px;
|
|
65
|
-
font-weight: 600;
|
|
66
|
-
color: var(--text-secondary);
|
|
67
|
-
margin: 0 0 12px;
|
|
68
|
-
display: flex;
|
|
69
|
-
align-items: center;
|
|
70
|
-
justify-content: space-between;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/* Key gate */
|
|
74
|
-
#keyGate { display: none; }
|
|
75
|
-
#keyGate.visible { display: block; }
|
|
76
|
-
#keyGate input {
|
|
77
|
-
font: inherit;
|
|
78
|
-
padding: 8px 10px;
|
|
79
|
-
border-radius: 6px;
|
|
80
|
-
border: 1px solid var(--axis);
|
|
81
|
-
background: var(--page);
|
|
82
|
-
color: var(--text-primary);
|
|
83
|
-
width: 320px;
|
|
84
|
-
max-width: 100%;
|
|
85
|
-
}
|
|
86
|
-
#keyGate button, .filter-row button, .view-toggle button, #forgetKey {
|
|
87
|
-
font: inherit;
|
|
88
|
-
padding: 7px 12px;
|
|
89
|
-
border-radius: 6px;
|
|
90
|
-
border: 1px solid var(--axis);
|
|
91
|
-
background: var(--surface-1);
|
|
92
|
-
color: var(--text-primary);
|
|
93
|
-
cursor: pointer;
|
|
94
|
-
}
|
|
95
|
-
#keyGate button:hover, .filter-row button:hover, .view-toggle button:hover, #forgetKey:hover {
|
|
96
|
-
background: var(--hover-wash);
|
|
97
|
-
}
|
|
98
|
-
#keyError { color: var(--status-critical); font-size: 13px; margin-top: 8px; }
|
|
99
|
-
|
|
100
|
-
#dashboard { display: none; }
|
|
101
|
-
#dashboard.visible { display: block; }
|
|
102
|
-
|
|
103
|
-
/* Filter row */
|
|
104
|
-
.filter-row {
|
|
105
|
-
display: flex;
|
|
106
|
-
align-items: center;
|
|
107
|
-
gap: 8px;
|
|
108
|
-
margin-bottom: 16px;
|
|
109
|
-
}
|
|
110
|
-
.filter-row button[aria-pressed="true"] {
|
|
111
|
-
background: var(--series-1);
|
|
112
|
-
border-color: var(--series-1);
|
|
113
|
-
color: #fff;
|
|
114
|
-
}
|
|
115
|
-
.filter-row .spacer { flex: 1; }
|
|
116
|
-
#forgetKey { font-size: 12px; color: var(--text-secondary); }
|
|
117
|
-
|
|
118
|
-
.refresh-control {
|
|
119
|
-
display: flex;
|
|
120
|
-
align-items: center;
|
|
121
|
-
gap: 6px;
|
|
122
|
-
font-size: 12px;
|
|
123
|
-
color: var(--text-secondary);
|
|
124
|
-
}
|
|
125
|
-
.refresh-control label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
|
|
126
|
-
#lastUpdated { color: var(--text-muted); }
|
|
127
|
-
|
|
128
|
-
/* KPI row */
|
|
129
|
-
.kpi-row {
|
|
130
|
-
display: grid;
|
|
131
|
-
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
132
|
-
gap: 12px;
|
|
133
|
-
margin-bottom: 16px;
|
|
134
|
-
}
|
|
135
|
-
.stat-tile {
|
|
136
|
-
background: var(--surface-1);
|
|
137
|
-
border: 1px solid var(--border);
|
|
138
|
-
border-radius: 10px;
|
|
139
|
-
padding: 14px 16px;
|
|
140
|
-
}
|
|
141
|
-
.stat-tile .label { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
|
|
142
|
-
.stat-tile .value { font-size: 24px; font-weight: 600; font-variant-numeric: proportional-nums; }
|
|
143
|
-
.stat-tile .value.critical { color: var(--status-critical); }
|
|
144
|
-
.stat-tile svg.spark { display: block; margin-top: 6px; }
|
|
145
|
-
|
|
146
|
-
/* Provider alerts table - the whole CARD stays out of the DOM (via
|
|
147
|
-
[hidden] on #providerAlertsCard) whenever no provider currently has
|
|
148
|
-
a classified error, so a healthy deployment shows nothing here at
|
|
149
|
-
all rather than an always-visible table that's usually empty. */
|
|
150
|
-
.data-table td.alert-mark { color: var(--status-critical); font-weight: 700; text-align: center; }
|
|
151
|
-
|
|
152
|
-
/* Charts */
|
|
153
|
-
.chart-wrap { position: relative; }
|
|
154
|
-
svg.chart { display: block; width: 100%; height: auto; overflow: visible; }
|
|
155
|
-
.axis-label { fill: var(--text-muted); font-size: 11px; }
|
|
156
|
-
.gridline { stroke: var(--grid); stroke-width: 1; }
|
|
157
|
-
.axis-line { stroke: var(--axis); stroke-width: 1; }
|
|
158
|
-
.direct-label { fill: var(--text-primary); font-size: 12px; font-weight: 600; }
|
|
159
|
-
|
|
160
|
-
.legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: var(--text-secondary); }
|
|
161
|
-
.legend .item { display: flex; align-items: center; gap: 6px; }
|
|
162
|
-
.legend .swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
|
|
163
|
-
|
|
164
|
-
.tooltip {
|
|
165
|
-
position: absolute;
|
|
166
|
-
pointer-events: none;
|
|
167
|
-
background: var(--surface-1);
|
|
168
|
-
border: 1px solid var(--border);
|
|
169
|
-
border-radius: 8px;
|
|
170
|
-
padding: 8px 10px;
|
|
171
|
-
font-size: 12px;
|
|
172
|
-
box-shadow: 0 2px 10px rgba(0,0,0,0.15);
|
|
173
|
-
white-space: nowrap;
|
|
174
|
-
opacity: 0;
|
|
175
|
-
transition: opacity 0.08s;
|
|
176
|
-
z-index: 10;
|
|
177
|
-
}
|
|
178
|
-
.tooltip.visible { opacity: 1; }
|
|
179
|
-
.tooltip .t-title { color: var(--text-secondary); margin-bottom: 4px; }
|
|
180
|
-
.tooltip .t-row { display: flex; align-items: center; gap: 6px; }
|
|
181
|
-
.tooltip .t-row + .t-row { margin-top: 2px; }
|
|
182
|
-
.tooltip .t-key { width: 10px; height: 2px; display: inline-block; }
|
|
183
|
-
.tooltip .t-value { font-weight: 600; }
|
|
184
|
-
.tooltip .t-note { color: var(--text-muted); margin-top: 4px; }
|
|
185
|
-
|
|
186
|
-
.view-toggle { text-align: right; }
|
|
187
|
-
.view-toggle button { font-size: 12px; padding: 4px 10px; }
|
|
188
|
-
|
|
189
|
-
table.data-table {
|
|
190
|
-
width: 100%;
|
|
191
|
-
border-collapse: collapse;
|
|
192
|
-
font-size: 13px;
|
|
193
|
-
}
|
|
194
|
-
table.data-table th, table.data-table td {
|
|
195
|
-
text-align: left;
|
|
196
|
-
padding: 6px 10px;
|
|
197
|
-
border-bottom: 1px solid var(--grid);
|
|
198
|
-
}
|
|
199
|
-
table.data-table th { color: var(--text-secondary); font-weight: 600; }
|
|
200
|
-
table.data-table td.num { font-variant-numeric: tabular-nums; text-align: right; }
|
|
201
|
-
table.data-table th.num { text-align: right; }
|
|
202
|
-
|
|
203
|
-
.loading { opacity: 0.5; transition: opacity 0.15s; }
|
|
204
|
-
.empty-note { color: var(--text-muted); font-size: 13px; padding: 20px 0; text-align: center; }
|
|
205
|
-
</style>
|
|
206
|
-
</head>
|
|
207
|
-
<body>
|
|
208
|
-
|
|
209
|
-
<h1>Model Router · Cost Dashboard</h1>
|
|
210
|
-
<p class="subtitle">Reads GET /dashboard/data. Nothing here is cached in the browser except your internal key.</p>
|
|
211
|
-
|
|
212
|
-
<div id="keyGate">
|
|
213
|
-
<div class="card">
|
|
214
|
-
<h2>Internal key required</h2>
|
|
215
|
-
<p style="color: var(--text-secondary); font-size: 13px; margin-top: 0;">
|
|
216
|
-
Paste your MODEL_ROUTER_INTERNAL_KEY. It's stored only in this browser's
|
|
217
|
-
localStorage, used only for requests to this same origin.
|
|
218
|
-
</p>
|
|
219
|
-
<input type="password" id="keyInput" placeholder="MODEL_ROUTER_INTERNAL_KEY" autocomplete="off">
|
|
220
|
-
<button id="keySubmit">Connect</button>
|
|
221
|
-
<div id="keyError"></div>
|
|
222
|
-
</div>
|
|
223
|
-
</div>
|
|
224
|
-
|
|
225
|
-
<div id="dashboard">
|
|
226
|
-
<div class="filter-row" role="group" aria-label="Date range">
|
|
227
|
-
<button data-days="7">Last 7 days</button>
|
|
228
|
-
<button data-days="14" aria-pressed="true">Last 14 days</button>
|
|
229
|
-
<button data-days="30">Last 30 days</button>
|
|
230
|
-
<span class="spacer"></span>
|
|
231
|
-
<span class="refresh-control">
|
|
232
|
-
<span id="lastUpdated"></span>
|
|
233
|
-
<label><input type="checkbox" id="autoRefreshToggle"> Auto-refresh</label>
|
|
234
|
-
</span>
|
|
235
|
-
<button id="forgetKey">Forget key</button>
|
|
236
|
-
</div>
|
|
237
|
-
|
|
238
|
-
<div class="kpi-row" id="kpiRow"></div>
|
|
239
|
-
|
|
240
|
-
<div class="card">
|
|
241
|
-
<h2>
|
|
242
|
-
<span>Cost over time</span>
|
|
243
|
-
<span class="view-toggle"><button data-toggle="costChart">View as table</button></span>
|
|
244
|
-
</h2>
|
|
245
|
-
<div class="chart-wrap" id="costChart"></div>
|
|
246
|
-
</div>
|
|
247
|
-
|
|
248
|
-
<div class="card">
|
|
249
|
-
<h2>
|
|
250
|
-
<span>Requests by outcome</span>
|
|
251
|
-
<span class="view-toggle"><button data-toggle="outcomeChart">View as table</button></span>
|
|
252
|
-
</h2>
|
|
253
|
-
<div class="legend" id="outcomeLegend"></div>
|
|
254
|
-
<div class="chart-wrap" id="outcomeChart" style="margin-top: 10px;"></div>
|
|
255
|
-
</div>
|
|
256
|
-
|
|
257
|
-
<div class="card">
|
|
258
|
-
<h2>
|
|
259
|
-
<span>Cost by provider</span>
|
|
260
|
-
<span class="view-toggle"><button data-toggle="providerChart">View as table</button></span>
|
|
261
|
-
</h2>
|
|
262
|
-
<div class="chart-wrap" id="providerChart"></div>
|
|
263
|
-
</div>
|
|
264
|
-
|
|
265
|
-
<div class="card">
|
|
266
|
-
<h2>Provider health</h2>
|
|
267
|
-
<div id="providerTable"></div>
|
|
268
|
-
</div>
|
|
269
|
-
|
|
270
|
-
<div class="card" id="providerAlertsCard" hidden>
|
|
271
|
-
<h2>Provider alerts</h2>
|
|
272
|
-
<div id="providerAlertsTable"></div>
|
|
273
|
-
</div>
|
|
274
|
-
</div>
|
|
275
|
-
|
|
276
|
-
<script>
|
|
277
|
-
(function () {
|
|
278
|
-
'use strict';
|
|
279
|
-
|
|
280
|
-
var STORAGE_KEY = 'modelRouterDashboardKey';
|
|
281
|
-
var SVG_NS = 'http://www.w3.org/2000/svg';
|
|
282
|
-
var currentDays = 14;
|
|
283
|
-
var lastData = null;
|
|
284
|
-
var viewMode = {}; // chartId -> 'chart' | 'table'
|
|
285
|
-
|
|
286
|
-
// ---------- formatting ----------
|
|
287
|
-
|
|
288
|
-
function formatUsd(n) {
|
|
289
|
-
if (n === null || n === undefined) return '—';
|
|
290
|
-
var abs = Math.abs(n);
|
|
291
|
-
if (abs === 0) return '$0.00';
|
|
292
|
-
if (abs < 0.01) return '$' + n.toFixed(4);
|
|
293
|
-
if (abs < 1000) return '$' + n.toFixed(2);
|
|
294
|
-
if (abs < 1000000) return '$' + (n / 1000).toFixed(1) + 'K';
|
|
295
|
-
return '$' + (n / 1000000).toFixed(1) + 'M';
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
function formatCount(n) {
|
|
299
|
-
if (n === null || n === undefined) return '—';
|
|
300
|
-
var abs = Math.abs(n);
|
|
301
|
-
if (abs < 1000) return String(n);
|
|
302
|
-
if (abs < 1000000) return (n / 1000).toFixed(1) + 'K';
|
|
303
|
-
return (n / 1000000).toFixed(1) + 'M';
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function formatPercent(x) {
|
|
307
|
-
if (x === null || x === undefined) return '—';
|
|
308
|
-
return (x * 100).toFixed(1) + '%';
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function formatMs(ms) {
|
|
312
|
-
if (ms === null || ms === undefined) return '—';
|
|
313
|
-
if (ms < 1000) return Math.round(ms) + ' ms';
|
|
314
|
-
return (ms / 1000).toFixed(2) + ' s';
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function formatDateLabel(dateStr) {
|
|
318
|
-
// dateStr is 'YYYY-MM-DD'
|
|
319
|
-
var parts = dateStr.split('-');
|
|
320
|
-
var d = new Date(Date.UTC(+parts[0], +parts[1] - 1, +parts[2]));
|
|
321
|
-
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' });
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// Classic "nice numbers" axis algorithm - y-axis ticks should land on
|
|
325
|
-
// clean values (0.10, 0.25, 0.50 / 0, 10, 20), never on whatever
|
|
326
|
-
// fraction of the raw max a fixed step count happens to produce.
|
|
327
|
-
function niceNumber(range, round) {
|
|
328
|
-
if (range <= 0) return 1;
|
|
329
|
-
var exponent = Math.floor(Math.log(range) / Math.LN10);
|
|
330
|
-
var fraction = range / Math.pow(10, exponent);
|
|
331
|
-
var niceFraction;
|
|
332
|
-
if (round) {
|
|
333
|
-
if (fraction < 1.5) niceFraction = 1;
|
|
334
|
-
else if (fraction < 3) niceFraction = 2;
|
|
335
|
-
else if (fraction < 7) niceFraction = 5;
|
|
336
|
-
else niceFraction = 10;
|
|
337
|
-
} else {
|
|
338
|
-
if (fraction <= 1) niceFraction = 1;
|
|
339
|
-
else if (fraction <= 2) niceFraction = 2;
|
|
340
|
-
else if (fraction <= 5) niceFraction = 5;
|
|
341
|
-
else niceFraction = 10;
|
|
342
|
-
}
|
|
343
|
-
return niceFraction * Math.pow(10, exponent);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function niceAxisTicks(maxValue, tickCount) {
|
|
347
|
-
tickCount = tickCount || 4;
|
|
348
|
-
if (!(maxValue > 0)) return { max: 1, ticks: [0, 0.25, 0.5, 0.75, 1] };
|
|
349
|
-
var niceRange = niceNumber(maxValue, false);
|
|
350
|
-
var step = niceNumber(niceRange / tickCount, true);
|
|
351
|
-
var niceMax = Math.ceil(maxValue / step) * step;
|
|
352
|
-
var ticks = [];
|
|
353
|
-
for (var v = 0; v <= niceMax + step / 2; v += step) ticks.push(v);
|
|
354
|
-
return { max: niceMax, ticks: ticks };
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// ---------- key gate ----------
|
|
358
|
-
|
|
359
|
-
function getStoredKey() {
|
|
360
|
-
try { return localStorage.getItem(STORAGE_KEY); } catch (e) { return null; }
|
|
361
|
-
}
|
|
362
|
-
function setStoredKey(key) {
|
|
363
|
-
try { localStorage.setItem(STORAGE_KEY, key); } catch (e) { /* private mode etc - key just won't persist */ }
|
|
364
|
-
}
|
|
365
|
-
function clearStoredKey() {
|
|
366
|
-
try { localStorage.removeItem(STORAGE_KEY); } catch (e) { /* ignore */ }
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function showKeyGate(errorText) {
|
|
370
|
-
document.getElementById('keyGate').classList.add('visible');
|
|
371
|
-
document.getElementById('dashboard').classList.remove('visible');
|
|
372
|
-
document.getElementById('keyError').textContent = errorText || '';
|
|
373
|
-
}
|
|
374
|
-
function showDashboard() {
|
|
375
|
-
document.getElementById('keyGate').classList.remove('visible');
|
|
376
|
-
document.getElementById('dashboard').classList.add('visible');
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
document.getElementById('keySubmit').addEventListener('click', function () {
|
|
380
|
-
var val = document.getElementById('keyInput').value.trim();
|
|
381
|
-
if (!val) return;
|
|
382
|
-
setStoredKey(val);
|
|
383
|
-
loadDashboard(currentDays);
|
|
384
|
-
});
|
|
385
|
-
document.getElementById('keyInput').addEventListener('keydown', function (e) {
|
|
386
|
-
if (e.key === 'Enter') document.getElementById('keySubmit').click();
|
|
387
|
-
});
|
|
388
|
-
document.getElementById('forgetKey').addEventListener('click', function () {
|
|
389
|
-
clearStoredKey();
|
|
390
|
-
showKeyGate('');
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
// ---------- data loading ----------
|
|
394
|
-
|
|
395
|
-
var lastUpdatedAt = null;
|
|
396
|
-
|
|
397
|
-
function loadDashboard(days) {
|
|
398
|
-
currentDays = days;
|
|
399
|
-
var key = getStoredKey();
|
|
400
|
-
if (!key) { showKeyGate(''); return; }
|
|
401
|
-
|
|
402
|
-
var dashboardEl = document.getElementById('dashboard');
|
|
403
|
-
if (lastData) dashboardEl.classList.add('loading'); // hold previous render, no skeleton flash
|
|
404
|
-
|
|
405
|
-
fetch('/dashboard/data?days=' + encodeURIComponent(days), {
|
|
406
|
-
headers: { Authorization: 'Bearer ' + key }
|
|
407
|
-
}).then(function (res) {
|
|
408
|
-
if (res.status === 401) {
|
|
409
|
-
clearStoredKey();
|
|
410
|
-
showKeyGate('That key was rejected. Please re-enter it.');
|
|
411
|
-
throw new Error('unauthorized');
|
|
412
|
-
}
|
|
413
|
-
if (!res.ok) throw new Error('request failed: ' + res.status);
|
|
414
|
-
return res.json();
|
|
415
|
-
}).then(function (data) {
|
|
416
|
-
lastData = data;
|
|
417
|
-
lastUpdatedAt = Date.now();
|
|
418
|
-
showDashboard();
|
|
419
|
-
dashboardEl.classList.remove('loading');
|
|
420
|
-
render(data);
|
|
421
|
-
updateLastUpdatedText();
|
|
422
|
-
}).catch(function (err) {
|
|
423
|
-
dashboardEl.classList.remove('loading');
|
|
424
|
-
if (err.message !== 'unauthorized') console.warn('Dashboard load failed:', err.message);
|
|
425
|
-
});
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
document.querySelectorAll('.filter-row button[data-days]').forEach(function (btn) {
|
|
429
|
-
btn.addEventListener('click', function () {
|
|
430
|
-
document.querySelectorAll('.filter-row button[data-days]').forEach(function (b) {
|
|
431
|
-
b.setAttribute('aria-pressed', String(b === btn));
|
|
432
|
-
});
|
|
433
|
-
loadDashboard(Number(btn.dataset.days));
|
|
434
|
-
});
|
|
435
|
-
});
|
|
436
|
-
|
|
437
|
-
// ---------- auto-refresh ----------
|
|
438
|
-
//
|
|
439
|
-
// Without this, a long-open tab shows numbers as of whenever it was
|
|
440
|
-
// last loaded (or a range button last clicked) with no indication
|
|
441
|
-
// they've gone stale - the only previous workaround was re-clicking
|
|
442
|
-
// the current range manually. Default ON, toggleable, and paused
|
|
443
|
-
// while the tab isn't visible (no point polling a backend nobody's
|
|
444
|
-
// looking at). The interval length is overridable via a global for
|
|
445
|
-
// fast, real automated testing - never meant to be set in production.
|
|
446
|
-
|
|
447
|
-
var REFRESH_INTERVAL_MS = window.__DASHBOARD_REFRESH_MS_OVERRIDE__ || 30000;
|
|
448
|
-
var AUTO_REFRESH_STORAGE_KEY = 'modelRouterDashboardAutoRefresh';
|
|
449
|
-
var refreshIntervalId = null;
|
|
450
|
-
|
|
451
|
-
function isAutoRefreshEnabled() {
|
|
452
|
-
try {
|
|
453
|
-
var stored = localStorage.getItem(AUTO_REFRESH_STORAGE_KEY);
|
|
454
|
-
return stored === null ? true : stored === 'true'; // default ON
|
|
455
|
-
} catch (e) {
|
|
456
|
-
return true;
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
function setAutoRefreshEnabled(enabled) {
|
|
461
|
-
try { localStorage.setItem(AUTO_REFRESH_STORAGE_KEY, String(enabled)); } catch (e) { /* ignore */ }
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
function startAutoRefresh() {
|
|
465
|
-
stopAutoRefresh();
|
|
466
|
-
refreshIntervalId = setInterval(function () {
|
|
467
|
-
if (document.hidden) return; // don't poll a backend nobody's looking at
|
|
468
|
-
loadDashboard(currentDays);
|
|
469
|
-
}, REFRESH_INTERVAL_MS);
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
function stopAutoRefresh() {
|
|
473
|
-
if (refreshIntervalId) {
|
|
474
|
-
clearInterval(refreshIntervalId);
|
|
475
|
-
refreshIntervalId = null;
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
function updateLastUpdatedText() {
|
|
480
|
-
var el = document.getElementById('lastUpdated');
|
|
481
|
-
if (!lastUpdatedAt) { el.textContent = ''; return; }
|
|
482
|
-
var seconds = Math.round((Date.now() - lastUpdatedAt) / 1000);
|
|
483
|
-
var text;
|
|
484
|
-
if (seconds < 5) text = 'Updated just now';
|
|
485
|
-
else if (seconds < 60) text = 'Updated ' + seconds + 's ago';
|
|
486
|
-
else text = 'Updated ' + Math.round(seconds / 60) + 'm ago';
|
|
487
|
-
el.textContent = text;
|
|
488
|
-
}
|
|
489
|
-
setInterval(updateLastUpdatedText, 1000);
|
|
490
|
-
|
|
491
|
-
var autoRefreshToggle = document.getElementById('autoRefreshToggle');
|
|
492
|
-
autoRefreshToggle.checked = isAutoRefreshEnabled();
|
|
493
|
-
if (autoRefreshToggle.checked) startAutoRefresh();
|
|
494
|
-
|
|
495
|
-
autoRefreshToggle.addEventListener('change', function () {
|
|
496
|
-
setAutoRefreshEnabled(autoRefreshToggle.checked);
|
|
497
|
-
if (autoRefreshToggle.checked) {
|
|
498
|
-
loadDashboard(currentDays); // refresh immediately on re-enable, don't wait a full interval
|
|
499
|
-
startAutoRefresh();
|
|
500
|
-
} else {
|
|
501
|
-
stopAutoRefresh();
|
|
502
|
-
}
|
|
503
|
-
});
|
|
504
|
-
|
|
505
|
-
// Coming back to a backgrounded tab is a natural moment to refresh,
|
|
506
|
-
// independent of the interval timer (which was skipping ticks while
|
|
507
|
-
// hidden anyway).
|
|
508
|
-
document.addEventListener('visibilitychange', function () {
|
|
509
|
-
if (!document.hidden && isAutoRefreshEnabled() && lastUpdatedAt) {
|
|
510
|
-
loadDashboard(currentDays);
|
|
511
|
-
}
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
document.querySelectorAll('.view-toggle button[data-toggle]').forEach(function (btn) {
|
|
515
|
-
btn.addEventListener('click', function () {
|
|
516
|
-
var id = btn.dataset.toggle;
|
|
517
|
-
viewMode[id] = viewMode[id] === 'table' ? 'chart' : 'table';
|
|
518
|
-
btn.textContent = viewMode[id] === 'table' ? 'View as chart' : 'View as table';
|
|
519
|
-
if (lastData) render(lastData);
|
|
520
|
-
});
|
|
521
|
-
});
|
|
522
|
-
|
|
523
|
-
// ---------- rendering ----------
|
|
524
|
-
|
|
525
|
-
function render(data) {
|
|
526
|
-
renderKpis(data);
|
|
527
|
-
renderCostChart(data);
|
|
528
|
-
renderOutcomeChart(data);
|
|
529
|
-
renderProviderChart(data);
|
|
530
|
-
renderProviderTable(data);
|
|
531
|
-
renderProviderAlertsTable(data);
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// ---- Provider alerts table - one row per provider currently in
|
|
535
|
-
// trouble, one column per known failure reason (a checkmark in
|
|
536
|
-
// whichever column matches). The whole CARD is [hidden] when nothing's
|
|
537
|
-
// wrong - a healthy deployment shows nothing here at all instead of an
|
|
538
|
-
// always-visible table that's usually empty.
|
|
539
|
-
//
|
|
540
|
-
// "Currently" here matches provider_alerts's own source (server.js's
|
|
541
|
-
// /dashboard/data: metrics.providerStats(), a ROLLING window of recent
|
|
542
|
-
// requests, the same one router.js itself uses to decide routing
|
|
543
|
-
// health) - not this page's own calendar day-range picker. A provider
|
|
544
|
-
// that was broken 10 days ago but has been fine since won't show up
|
|
545
|
-
// here just because "Last 30 days" is selected; that's what the
|
|
546
|
-
// "Provider health" table right above this one is for.
|
|
547
|
-
var ALERT_COLUMNS = [
|
|
548
|
-
{ key: 'authentication_error', label: 'Authentication' },
|
|
549
|
-
{ key: 'insufficient_quota', label: 'Insufficient quota' },
|
|
550
|
-
{ key: 'rate_limit_error', label: 'Rate limit' }
|
|
551
|
-
];
|
|
552
|
-
|
|
553
|
-
function renderProviderAlertsTable(data) {
|
|
554
|
-
var card = document.getElementById('providerAlertsCard');
|
|
555
|
-
var container = document.getElementById('providerAlertsTable');
|
|
556
|
-
container.innerHTML = '';
|
|
557
|
-
var alerts = data.provider_alerts || [];
|
|
558
|
-
if (!alerts.length) {
|
|
559
|
-
card.hidden = true;
|
|
560
|
-
return;
|
|
561
|
-
}
|
|
562
|
-
card.hidden = false;
|
|
563
|
-
|
|
564
|
-
var headers = ['Provider'].concat(ALERT_COLUMNS.map(function (c) { return c.label; }), ['Other']);
|
|
565
|
-
var rows = alerts.map(function (a) {
|
|
566
|
-
var row = [a.provider];
|
|
567
|
-
var matchedKnownColumn = false;
|
|
568
|
-
ALERT_COLUMNS.forEach(function (c) {
|
|
569
|
-
if (a.error_type === c.key) {
|
|
570
|
-
row.push('✓');
|
|
571
|
-
matchedKnownColumn = true;
|
|
572
|
-
} else {
|
|
573
|
-
row.push('');
|
|
574
|
-
}
|
|
575
|
-
});
|
|
576
|
-
// Whatever the provider itself called it, if it's not one of the
|
|
577
|
-
// three known buckets above - still shown, not silently dropped,
|
|
578
|
-
// same "unknown is more honest than guessing" reasoning as
|
|
579
|
-
// classifyErrorType() itself.
|
|
580
|
-
row.push(matchedKnownColumn ? '' : '✓ (' + a.error_type.replace(/_/g, ' ') + ')');
|
|
581
|
-
return row;
|
|
582
|
-
});
|
|
583
|
-
|
|
584
|
-
var table = buildTable(headers, rows, headers.map(function (_, i) { return i > 0; }));
|
|
585
|
-
// Mark every checkmark cell so it reads as a clear signal, not just
|
|
586
|
-
// more plain table text - matches the KPI tiles' own .critical
|
|
587
|
-
// treatment for "this number means trouble" elsewhere on this page.
|
|
588
|
-
Array.prototype.forEach.call(table.querySelectorAll('td.num'), function (td) {
|
|
589
|
-
if (td.textContent.indexOf('✓') !== -1) td.classList.add('alert-mark');
|
|
590
|
-
});
|
|
591
|
-
container.appendChild(table);
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
function renderKpis(data) {
|
|
595
|
-
var tiles = [
|
|
596
|
-
{ label: 'Total cost (' + data.days + 'd)', value: formatUsd(data.total_cost_usd), spark: data.daily.map(function (d) { return d.cost_usd; }) },
|
|
597
|
-
{ label: '
|
|
598
|
-
{ label: '
|
|
599
|
-
{ label: '
|
|
600
|
-
{ label: '
|
|
601
|
-
{ label: '
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
row
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
tile
|
|
608
|
-
|
|
609
|
-
label
|
|
610
|
-
label.
|
|
611
|
-
|
|
612
|
-
value
|
|
613
|
-
value.
|
|
614
|
-
|
|
615
|
-
tile.appendChild(
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
var
|
|
626
|
-
var
|
|
627
|
-
var
|
|
628
|
-
var
|
|
629
|
-
svg.
|
|
630
|
-
svg.setAttribute('
|
|
631
|
-
svg.setAttribute('
|
|
632
|
-
svg.setAttribute('
|
|
633
|
-
|
|
634
|
-
var
|
|
635
|
-
|
|
636
|
-
var
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
var
|
|
641
|
-
|
|
642
|
-
path.setAttribute('
|
|
643
|
-
path.setAttribute('
|
|
644
|
-
path.setAttribute('stroke
|
|
645
|
-
path.setAttribute('stroke-
|
|
646
|
-
path.setAttribute('stroke-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
var
|
|
650
|
-
dot.
|
|
651
|
-
dot.setAttribute('
|
|
652
|
-
dot.setAttribute('
|
|
653
|
-
dot.setAttribute('
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
tip
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
var
|
|
671
|
-
var
|
|
672
|
-
|
|
673
|
-
tip.style.
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
titleEl
|
|
680
|
-
titleEl.
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
rowEl
|
|
685
|
-
|
|
686
|
-
key
|
|
687
|
-
key.
|
|
688
|
-
|
|
689
|
-
label
|
|
690
|
-
|
|
691
|
-
value
|
|
692
|
-
value.
|
|
693
|
-
|
|
694
|
-
rowEl.appendChild(
|
|
695
|
-
rowEl.appendChild(
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
noteEl
|
|
701
|
-
noteEl.
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
container
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
[
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
var
|
|
728
|
-
var
|
|
729
|
-
var
|
|
730
|
-
var
|
|
731
|
-
var
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
svg.
|
|
735
|
-
svg.setAttribute('
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
//
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
var
|
|
742
|
-
line.
|
|
743
|
-
line.setAttribute('
|
|
744
|
-
line.setAttribute('
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
label.
|
|
748
|
-
label.setAttribute('
|
|
749
|
-
label.setAttribute('
|
|
750
|
-
label.setAttribute('
|
|
751
|
-
label.
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
var
|
|
757
|
-
|
|
758
|
-
var
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
label.
|
|
767
|
-
label.setAttribute('
|
|
768
|
-
label.setAttribute('
|
|
769
|
-
label.setAttribute('
|
|
770
|
-
label.
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
var
|
|
776
|
-
|
|
777
|
-
path.setAttribute('
|
|
778
|
-
path.setAttribute('
|
|
779
|
-
path.setAttribute('stroke
|
|
780
|
-
path.setAttribute('stroke-
|
|
781
|
-
path.setAttribute('stroke-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
var
|
|
787
|
-
endDot.
|
|
788
|
-
endDot.setAttribute('
|
|
789
|
-
endDot.setAttribute('
|
|
790
|
-
endDot.setAttribute('
|
|
791
|
-
endDot.setAttribute('stroke
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
endLabel.
|
|
795
|
-
endLabel.setAttribute('
|
|
796
|
-
endLabel.setAttribute('
|
|
797
|
-
endLabel.
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
crosshair.
|
|
803
|
-
crosshair.setAttribute('
|
|
804
|
-
crosshair.setAttribute('
|
|
805
|
-
crosshair.
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
hitLayer.
|
|
810
|
-
hitLayer.setAttribute('
|
|
811
|
-
hitLayer.setAttribute('
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
var
|
|
820
|
-
var
|
|
821
|
-
var
|
|
822
|
-
idx = Math.
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
crosshair.
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
hitLayer.addEventListener('
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
var
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
{ key: '
|
|
850
|
-
{ key: '
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
item
|
|
857
|
-
|
|
858
|
-
sw
|
|
859
|
-
sw.
|
|
860
|
-
|
|
861
|
-
label
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
var
|
|
884
|
-
var
|
|
885
|
-
var
|
|
886
|
-
var
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
svg.
|
|
890
|
-
svg.setAttribute('
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
var
|
|
895
|
-
line.
|
|
896
|
-
line.setAttribute('
|
|
897
|
-
line.setAttribute('
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
label.
|
|
901
|
-
label.setAttribute('
|
|
902
|
-
label.setAttribute('
|
|
903
|
-
label.setAttribute('
|
|
904
|
-
label.
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
var
|
|
910
|
-
var
|
|
911
|
-
var
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
var
|
|
919
|
-
var
|
|
920
|
-
|
|
921
|
-
{ value: d.
|
|
922
|
-
{ value: d.
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
var
|
|
929
|
-
rect.
|
|
930
|
-
rect.setAttribute('
|
|
931
|
-
rect.setAttribute('
|
|
932
|
-
rect.setAttribute('
|
|
933
|
-
rect.setAttribute('
|
|
934
|
-
rect.setAttribute('
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
hitRect.
|
|
941
|
-
hitRect.setAttribute('
|
|
942
|
-
hitRect.setAttribute('
|
|
943
|
-
hitRect.setAttribute('
|
|
944
|
-
hitRect.setAttribute('
|
|
945
|
-
hitRect.
|
|
946
|
-
|
|
947
|
-
var
|
|
948
|
-
|
|
949
|
-
{ color: 'var(--series-
|
|
950
|
-
{ color: 'var(--series-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
tip.
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
label.
|
|
963
|
-
label.setAttribute('
|
|
964
|
-
label.setAttribute('
|
|
965
|
-
label.setAttribute('
|
|
966
|
-
label.
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
container
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
[
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
var
|
|
997
|
-
var
|
|
998
|
-
var
|
|
999
|
-
var
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
svg.
|
|
1003
|
-
svg.setAttribute('
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
var
|
|
1011
|
-
var
|
|
1012
|
-
var
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
label.
|
|
1016
|
-
label.setAttribute('
|
|
1017
|
-
label.setAttribute('
|
|
1018
|
-
label.setAttribute('
|
|
1019
|
-
label.
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
rect.
|
|
1024
|
-
rect.setAttribute('
|
|
1025
|
-
rect.setAttribute('
|
|
1026
|
-
rect.setAttribute('
|
|
1027
|
-
rect.setAttribute('
|
|
1028
|
-
rect.setAttribute('
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
valueLabel.
|
|
1033
|
-
valueLabel.setAttribute('
|
|
1034
|
-
valueLabel.setAttribute('
|
|
1035
|
-
valueLabel.
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
hitRect.
|
|
1040
|
-
hitRect.setAttribute('
|
|
1041
|
-
hitRect.setAttribute('
|
|
1042
|
-
hitRect.setAttribute('
|
|
1043
|
-
hitRect.setAttribute('
|
|
1044
|
-
hitRect.
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
{ color: color, label: '
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
container
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
table
|
|
1083
|
-
|
|
1084
|
-
var
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
th.
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
td.
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
</
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Model Router · Cost Dashboard</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: light;
|
|
10
|
+
--page: #f9f9f7;
|
|
11
|
+
--surface-1: #fcfcfb;
|
|
12
|
+
--text-primary: #0b0b0b;
|
|
13
|
+
--text-secondary: #52514e;
|
|
14
|
+
--text-muted: #898781;
|
|
15
|
+
--grid: #e1e0d9;
|
|
16
|
+
--axis: #c3c2b7;
|
|
17
|
+
--border: rgba(11,11,11,0.10);
|
|
18
|
+
--series-1: #2a78d6; /* exact hit / cost line / provider bar 1 */
|
|
19
|
+
--series-2: #eb6834; /* semantic hit / provider bar 2 */
|
|
20
|
+
--series-3: #1baf7a; /* miss */
|
|
21
|
+
--status-critical: #d03b3b;
|
|
22
|
+
--status-good: #0ca30c;
|
|
23
|
+
--hover-wash: rgba(11,11,11,0.05);
|
|
24
|
+
}
|
|
25
|
+
@media (prefers-color-scheme: dark) {
|
|
26
|
+
:root {
|
|
27
|
+
color-scheme: dark;
|
|
28
|
+
--page: #0d0d0d;
|
|
29
|
+
--surface-1: #1a1a19;
|
|
30
|
+
--text-primary: #ffffff;
|
|
31
|
+
--text-secondary: #c3c2b7;
|
|
32
|
+
--text-muted: #898781;
|
|
33
|
+
--grid: #2c2c2a;
|
|
34
|
+
--axis: #383835;
|
|
35
|
+
--border: rgba(255,255,255,0.10);
|
|
36
|
+
--series-1: #3987e5;
|
|
37
|
+
--series-2: #d95926;
|
|
38
|
+
--series-3: #199e70;
|
|
39
|
+
--status-critical: #d03b3b;
|
|
40
|
+
--status-good: #0ca30c;
|
|
41
|
+
--hover-wash: rgba(255,255,255,0.06);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
* { box-sizing: border-box; }
|
|
46
|
+
html, body {
|
|
47
|
+
margin: 0;
|
|
48
|
+
background: var(--page);
|
|
49
|
+
color: var(--text-primary);
|
|
50
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
51
|
+
}
|
|
52
|
+
body { padding: 24px; max-width: 1100px; margin: 0 auto; }
|
|
53
|
+
h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
|
|
54
|
+
.subtitle { color: var(--text-secondary); font-size: 13px; margin: 0 0 20px; }
|
|
55
|
+
|
|
56
|
+
.card {
|
|
57
|
+
background: var(--surface-1);
|
|
58
|
+
border: 1px solid var(--border);
|
|
59
|
+
border-radius: 10px;
|
|
60
|
+
padding: 16px;
|
|
61
|
+
margin-bottom: 16px;
|
|
62
|
+
}
|
|
63
|
+
.card h2 {
|
|
64
|
+
font-size: 13px;
|
|
65
|
+
font-weight: 600;
|
|
66
|
+
color: var(--text-secondary);
|
|
67
|
+
margin: 0 0 12px;
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
justify-content: space-between;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* Key gate */
|
|
74
|
+
#keyGate { display: none; }
|
|
75
|
+
#keyGate.visible { display: block; }
|
|
76
|
+
#keyGate input {
|
|
77
|
+
font: inherit;
|
|
78
|
+
padding: 8px 10px;
|
|
79
|
+
border-radius: 6px;
|
|
80
|
+
border: 1px solid var(--axis);
|
|
81
|
+
background: var(--page);
|
|
82
|
+
color: var(--text-primary);
|
|
83
|
+
width: 320px;
|
|
84
|
+
max-width: 100%;
|
|
85
|
+
}
|
|
86
|
+
#keyGate button, .filter-row button, .view-toggle button, #forgetKey {
|
|
87
|
+
font: inherit;
|
|
88
|
+
padding: 7px 12px;
|
|
89
|
+
border-radius: 6px;
|
|
90
|
+
border: 1px solid var(--axis);
|
|
91
|
+
background: var(--surface-1);
|
|
92
|
+
color: var(--text-primary);
|
|
93
|
+
cursor: pointer;
|
|
94
|
+
}
|
|
95
|
+
#keyGate button:hover, .filter-row button:hover, .view-toggle button:hover, #forgetKey:hover {
|
|
96
|
+
background: var(--hover-wash);
|
|
97
|
+
}
|
|
98
|
+
#keyError { color: var(--status-critical); font-size: 13px; margin-top: 8px; }
|
|
99
|
+
|
|
100
|
+
#dashboard { display: none; }
|
|
101
|
+
#dashboard.visible { display: block; }
|
|
102
|
+
|
|
103
|
+
/* Filter row */
|
|
104
|
+
.filter-row {
|
|
105
|
+
display: flex;
|
|
106
|
+
align-items: center;
|
|
107
|
+
gap: 8px;
|
|
108
|
+
margin-bottom: 16px;
|
|
109
|
+
}
|
|
110
|
+
.filter-row button[aria-pressed="true"] {
|
|
111
|
+
background: var(--series-1);
|
|
112
|
+
border-color: var(--series-1);
|
|
113
|
+
color: #fff;
|
|
114
|
+
}
|
|
115
|
+
.filter-row .spacer { flex: 1; }
|
|
116
|
+
#forgetKey { font-size: 12px; color: var(--text-secondary); }
|
|
117
|
+
|
|
118
|
+
.refresh-control {
|
|
119
|
+
display: flex;
|
|
120
|
+
align-items: center;
|
|
121
|
+
gap: 6px;
|
|
122
|
+
font-size: 12px;
|
|
123
|
+
color: var(--text-secondary);
|
|
124
|
+
}
|
|
125
|
+
.refresh-control label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
|
|
126
|
+
#lastUpdated { color: var(--text-muted); }
|
|
127
|
+
|
|
128
|
+
/* KPI row */
|
|
129
|
+
.kpi-row {
|
|
130
|
+
display: grid;
|
|
131
|
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
132
|
+
gap: 12px;
|
|
133
|
+
margin-bottom: 16px;
|
|
134
|
+
}
|
|
135
|
+
.stat-tile {
|
|
136
|
+
background: var(--surface-1);
|
|
137
|
+
border: 1px solid var(--border);
|
|
138
|
+
border-radius: 10px;
|
|
139
|
+
padding: 14px 16px;
|
|
140
|
+
}
|
|
141
|
+
.stat-tile .label { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
|
|
142
|
+
.stat-tile .value { font-size: 24px; font-weight: 600; font-variant-numeric: proportional-nums; }
|
|
143
|
+
.stat-tile .value.critical { color: var(--status-critical); }
|
|
144
|
+
.stat-tile svg.spark { display: block; margin-top: 6px; }
|
|
145
|
+
|
|
146
|
+
/* Provider alerts table - the whole CARD stays out of the DOM (via
|
|
147
|
+
[hidden] on #providerAlertsCard) whenever no provider currently has
|
|
148
|
+
a classified error, so a healthy deployment shows nothing here at
|
|
149
|
+
all rather than an always-visible table that's usually empty. */
|
|
150
|
+
.data-table td.alert-mark { color: var(--status-critical); font-weight: 700; text-align: center; }
|
|
151
|
+
|
|
152
|
+
/* Charts */
|
|
153
|
+
.chart-wrap { position: relative; }
|
|
154
|
+
svg.chart { display: block; width: 100%; height: auto; overflow: visible; }
|
|
155
|
+
.axis-label { fill: var(--text-muted); font-size: 11px; }
|
|
156
|
+
.gridline { stroke: var(--grid); stroke-width: 1; }
|
|
157
|
+
.axis-line { stroke: var(--axis); stroke-width: 1; }
|
|
158
|
+
.direct-label { fill: var(--text-primary); font-size: 12px; font-weight: 600; }
|
|
159
|
+
|
|
160
|
+
.legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: var(--text-secondary); }
|
|
161
|
+
.legend .item { display: flex; align-items: center; gap: 6px; }
|
|
162
|
+
.legend .swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
|
|
163
|
+
|
|
164
|
+
.tooltip {
|
|
165
|
+
position: absolute;
|
|
166
|
+
pointer-events: none;
|
|
167
|
+
background: var(--surface-1);
|
|
168
|
+
border: 1px solid var(--border);
|
|
169
|
+
border-radius: 8px;
|
|
170
|
+
padding: 8px 10px;
|
|
171
|
+
font-size: 12px;
|
|
172
|
+
box-shadow: 0 2px 10px rgba(0,0,0,0.15);
|
|
173
|
+
white-space: nowrap;
|
|
174
|
+
opacity: 0;
|
|
175
|
+
transition: opacity 0.08s;
|
|
176
|
+
z-index: 10;
|
|
177
|
+
}
|
|
178
|
+
.tooltip.visible { opacity: 1; }
|
|
179
|
+
.tooltip .t-title { color: var(--text-secondary); margin-bottom: 4px; }
|
|
180
|
+
.tooltip .t-row { display: flex; align-items: center; gap: 6px; }
|
|
181
|
+
.tooltip .t-row + .t-row { margin-top: 2px; }
|
|
182
|
+
.tooltip .t-key { width: 10px; height: 2px; display: inline-block; }
|
|
183
|
+
.tooltip .t-value { font-weight: 600; }
|
|
184
|
+
.tooltip .t-note { color: var(--text-muted); margin-top: 4px; }
|
|
185
|
+
|
|
186
|
+
.view-toggle { text-align: right; }
|
|
187
|
+
.view-toggle button { font-size: 12px; padding: 4px 10px; }
|
|
188
|
+
|
|
189
|
+
table.data-table {
|
|
190
|
+
width: 100%;
|
|
191
|
+
border-collapse: collapse;
|
|
192
|
+
font-size: 13px;
|
|
193
|
+
}
|
|
194
|
+
table.data-table th, table.data-table td {
|
|
195
|
+
text-align: left;
|
|
196
|
+
padding: 6px 10px;
|
|
197
|
+
border-bottom: 1px solid var(--grid);
|
|
198
|
+
}
|
|
199
|
+
table.data-table th { color: var(--text-secondary); font-weight: 600; }
|
|
200
|
+
table.data-table td.num { font-variant-numeric: tabular-nums; text-align: right; }
|
|
201
|
+
table.data-table th.num { text-align: right; }
|
|
202
|
+
|
|
203
|
+
.loading { opacity: 0.5; transition: opacity 0.15s; }
|
|
204
|
+
.empty-note { color: var(--text-muted); font-size: 13px; padding: 20px 0; text-align: center; }
|
|
205
|
+
</style>
|
|
206
|
+
</head>
|
|
207
|
+
<body>
|
|
208
|
+
|
|
209
|
+
<h1>Model Router · Cost Dashboard</h1>
|
|
210
|
+
<p class="subtitle">Reads GET /dashboard/data. Nothing here is cached in the browser except your internal key.</p>
|
|
211
|
+
|
|
212
|
+
<div id="keyGate">
|
|
213
|
+
<div class="card">
|
|
214
|
+
<h2>Internal key required</h2>
|
|
215
|
+
<p style="color: var(--text-secondary); font-size: 13px; margin-top: 0;">
|
|
216
|
+
Paste your MODEL_ROUTER_INTERNAL_KEY. It's stored only in this browser's
|
|
217
|
+
localStorage, used only for requests to this same origin.
|
|
218
|
+
</p>
|
|
219
|
+
<input type="password" id="keyInput" placeholder="MODEL_ROUTER_INTERNAL_KEY" autocomplete="off">
|
|
220
|
+
<button id="keySubmit">Connect</button>
|
|
221
|
+
<div id="keyError"></div>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<div id="dashboard">
|
|
226
|
+
<div class="filter-row" role="group" aria-label="Date range">
|
|
227
|
+
<button data-days="7">Last 7 days</button>
|
|
228
|
+
<button data-days="14" aria-pressed="true">Last 14 days</button>
|
|
229
|
+
<button data-days="30">Last 30 days</button>
|
|
230
|
+
<span class="spacer"></span>
|
|
231
|
+
<span class="refresh-control">
|
|
232
|
+
<span id="lastUpdated"></span>
|
|
233
|
+
<label><input type="checkbox" id="autoRefreshToggle"> Auto-refresh</label>
|
|
234
|
+
</span>
|
|
235
|
+
<button id="forgetKey">Forget key</button>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
<div class="kpi-row" id="kpiRow"></div>
|
|
239
|
+
|
|
240
|
+
<div class="card">
|
|
241
|
+
<h2>
|
|
242
|
+
<span>Cost over time</span>
|
|
243
|
+
<span class="view-toggle"><button data-toggle="costChart">View as table</button></span>
|
|
244
|
+
</h2>
|
|
245
|
+
<div class="chart-wrap" id="costChart"></div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<div class="card">
|
|
249
|
+
<h2>
|
|
250
|
+
<span>Requests by outcome</span>
|
|
251
|
+
<span class="view-toggle"><button data-toggle="outcomeChart">View as table</button></span>
|
|
252
|
+
</h2>
|
|
253
|
+
<div class="legend" id="outcomeLegend"></div>
|
|
254
|
+
<div class="chart-wrap" id="outcomeChart" style="margin-top: 10px;"></div>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
<div class="card">
|
|
258
|
+
<h2>
|
|
259
|
+
<span>Cost by provider</span>
|
|
260
|
+
<span class="view-toggle"><button data-toggle="providerChart">View as table</button></span>
|
|
261
|
+
</h2>
|
|
262
|
+
<div class="chart-wrap" id="providerChart"></div>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
<div class="card">
|
|
266
|
+
<h2>Provider health</h2>
|
|
267
|
+
<div id="providerTable"></div>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
<div class="card" id="providerAlertsCard" hidden>
|
|
271
|
+
<h2>Provider alerts</h2>
|
|
272
|
+
<div id="providerAlertsTable"></div>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<script>
|
|
277
|
+
(function () {
|
|
278
|
+
'use strict';
|
|
279
|
+
|
|
280
|
+
var STORAGE_KEY = 'modelRouterDashboardKey';
|
|
281
|
+
var SVG_NS = 'http://www.w3.org/2000/svg';
|
|
282
|
+
var currentDays = 14;
|
|
283
|
+
var lastData = null;
|
|
284
|
+
var viewMode = {}; // chartId -> 'chart' | 'table'
|
|
285
|
+
|
|
286
|
+
// ---------- formatting ----------
|
|
287
|
+
|
|
288
|
+
function formatUsd(n) {
|
|
289
|
+
if (n === null || n === undefined) return '—';
|
|
290
|
+
var abs = Math.abs(n);
|
|
291
|
+
if (abs === 0) return '$0.00';
|
|
292
|
+
if (abs < 0.01) return '$' + n.toFixed(4);
|
|
293
|
+
if (abs < 1000) return '$' + n.toFixed(2);
|
|
294
|
+
if (abs < 1000000) return '$' + (n / 1000).toFixed(1) + 'K';
|
|
295
|
+
return '$' + (n / 1000000).toFixed(1) + 'M';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function formatCount(n) {
|
|
299
|
+
if (n === null || n === undefined) return '—';
|
|
300
|
+
var abs = Math.abs(n);
|
|
301
|
+
if (abs < 1000) return String(n);
|
|
302
|
+
if (abs < 1000000) return (n / 1000).toFixed(1) + 'K';
|
|
303
|
+
return (n / 1000000).toFixed(1) + 'M';
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function formatPercent(x) {
|
|
307
|
+
if (x === null || x === undefined) return '—';
|
|
308
|
+
return (x * 100).toFixed(1) + '%';
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function formatMs(ms) {
|
|
312
|
+
if (ms === null || ms === undefined) return '—';
|
|
313
|
+
if (ms < 1000) return Math.round(ms) + ' ms';
|
|
314
|
+
return (ms / 1000).toFixed(2) + ' s';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function formatDateLabel(dateStr) {
|
|
318
|
+
// dateStr is 'YYYY-MM-DD'
|
|
319
|
+
var parts = dateStr.split('-');
|
|
320
|
+
var d = new Date(Date.UTC(+parts[0], +parts[1] - 1, +parts[2]));
|
|
321
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Classic "nice numbers" axis algorithm - y-axis ticks should land on
|
|
325
|
+
// clean values (0.10, 0.25, 0.50 / 0, 10, 20), never on whatever
|
|
326
|
+
// fraction of the raw max a fixed step count happens to produce.
|
|
327
|
+
function niceNumber(range, round) {
|
|
328
|
+
if (range <= 0) return 1;
|
|
329
|
+
var exponent = Math.floor(Math.log(range) / Math.LN10);
|
|
330
|
+
var fraction = range / Math.pow(10, exponent);
|
|
331
|
+
var niceFraction;
|
|
332
|
+
if (round) {
|
|
333
|
+
if (fraction < 1.5) niceFraction = 1;
|
|
334
|
+
else if (fraction < 3) niceFraction = 2;
|
|
335
|
+
else if (fraction < 7) niceFraction = 5;
|
|
336
|
+
else niceFraction = 10;
|
|
337
|
+
} else {
|
|
338
|
+
if (fraction <= 1) niceFraction = 1;
|
|
339
|
+
else if (fraction <= 2) niceFraction = 2;
|
|
340
|
+
else if (fraction <= 5) niceFraction = 5;
|
|
341
|
+
else niceFraction = 10;
|
|
342
|
+
}
|
|
343
|
+
return niceFraction * Math.pow(10, exponent);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function niceAxisTicks(maxValue, tickCount) {
|
|
347
|
+
tickCount = tickCount || 4;
|
|
348
|
+
if (!(maxValue > 0)) return { max: 1, ticks: [0, 0.25, 0.5, 0.75, 1] };
|
|
349
|
+
var niceRange = niceNumber(maxValue, false);
|
|
350
|
+
var step = niceNumber(niceRange / tickCount, true);
|
|
351
|
+
var niceMax = Math.ceil(maxValue / step) * step;
|
|
352
|
+
var ticks = [];
|
|
353
|
+
for (var v = 0; v <= niceMax + step / 2; v += step) ticks.push(v);
|
|
354
|
+
return { max: niceMax, ticks: ticks };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------- key gate ----------
|
|
358
|
+
|
|
359
|
+
function getStoredKey() {
|
|
360
|
+
try { return localStorage.getItem(STORAGE_KEY); } catch (e) { return null; }
|
|
361
|
+
}
|
|
362
|
+
function setStoredKey(key) {
|
|
363
|
+
try { localStorage.setItem(STORAGE_KEY, key); } catch (e) { /* private mode etc - key just won't persist */ }
|
|
364
|
+
}
|
|
365
|
+
function clearStoredKey() {
|
|
366
|
+
try { localStorage.removeItem(STORAGE_KEY); } catch (e) { /* ignore */ }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function showKeyGate(errorText) {
|
|
370
|
+
document.getElementById('keyGate').classList.add('visible');
|
|
371
|
+
document.getElementById('dashboard').classList.remove('visible');
|
|
372
|
+
document.getElementById('keyError').textContent = errorText || '';
|
|
373
|
+
}
|
|
374
|
+
function showDashboard() {
|
|
375
|
+
document.getElementById('keyGate').classList.remove('visible');
|
|
376
|
+
document.getElementById('dashboard').classList.add('visible');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
document.getElementById('keySubmit').addEventListener('click', function () {
|
|
380
|
+
var val = document.getElementById('keyInput').value.trim();
|
|
381
|
+
if (!val) return;
|
|
382
|
+
setStoredKey(val);
|
|
383
|
+
loadDashboard(currentDays);
|
|
384
|
+
});
|
|
385
|
+
document.getElementById('keyInput').addEventListener('keydown', function (e) {
|
|
386
|
+
if (e.key === 'Enter') document.getElementById('keySubmit').click();
|
|
387
|
+
});
|
|
388
|
+
document.getElementById('forgetKey').addEventListener('click', function () {
|
|
389
|
+
clearStoredKey();
|
|
390
|
+
showKeyGate('');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// ---------- data loading ----------
|
|
394
|
+
|
|
395
|
+
var lastUpdatedAt = null;
|
|
396
|
+
|
|
397
|
+
function loadDashboard(days) {
|
|
398
|
+
currentDays = days;
|
|
399
|
+
var key = getStoredKey();
|
|
400
|
+
if (!key) { showKeyGate(''); return; }
|
|
401
|
+
|
|
402
|
+
var dashboardEl = document.getElementById('dashboard');
|
|
403
|
+
if (lastData) dashboardEl.classList.add('loading'); // hold previous render, no skeleton flash
|
|
404
|
+
|
|
405
|
+
fetch('/dashboard/data?days=' + encodeURIComponent(days), {
|
|
406
|
+
headers: { Authorization: 'Bearer ' + key }
|
|
407
|
+
}).then(function (res) {
|
|
408
|
+
if (res.status === 401) {
|
|
409
|
+
clearStoredKey();
|
|
410
|
+
showKeyGate('That key was rejected. Please re-enter it.');
|
|
411
|
+
throw new Error('unauthorized');
|
|
412
|
+
}
|
|
413
|
+
if (!res.ok) throw new Error('request failed: ' + res.status);
|
|
414
|
+
return res.json();
|
|
415
|
+
}).then(function (data) {
|
|
416
|
+
lastData = data;
|
|
417
|
+
lastUpdatedAt = Date.now();
|
|
418
|
+
showDashboard();
|
|
419
|
+
dashboardEl.classList.remove('loading');
|
|
420
|
+
render(data);
|
|
421
|
+
updateLastUpdatedText();
|
|
422
|
+
}).catch(function (err) {
|
|
423
|
+
dashboardEl.classList.remove('loading');
|
|
424
|
+
if (err.message !== 'unauthorized') console.warn('Dashboard load failed:', err.message);
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
document.querySelectorAll('.filter-row button[data-days]').forEach(function (btn) {
|
|
429
|
+
btn.addEventListener('click', function () {
|
|
430
|
+
document.querySelectorAll('.filter-row button[data-days]').forEach(function (b) {
|
|
431
|
+
b.setAttribute('aria-pressed', String(b === btn));
|
|
432
|
+
});
|
|
433
|
+
loadDashboard(Number(btn.dataset.days));
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// ---------- auto-refresh ----------
|
|
438
|
+
//
|
|
439
|
+
// Without this, a long-open tab shows numbers as of whenever it was
|
|
440
|
+
// last loaded (or a range button last clicked) with no indication
|
|
441
|
+
// they've gone stale - the only previous workaround was re-clicking
|
|
442
|
+
// the current range manually. Default ON, toggleable, and paused
|
|
443
|
+
// while the tab isn't visible (no point polling a backend nobody's
|
|
444
|
+
// looking at). The interval length is overridable via a global for
|
|
445
|
+
// fast, real automated testing - never meant to be set in production.
|
|
446
|
+
|
|
447
|
+
var REFRESH_INTERVAL_MS = window.__DASHBOARD_REFRESH_MS_OVERRIDE__ || 30000;
|
|
448
|
+
var AUTO_REFRESH_STORAGE_KEY = 'modelRouterDashboardAutoRefresh';
|
|
449
|
+
var refreshIntervalId = null;
|
|
450
|
+
|
|
451
|
+
function isAutoRefreshEnabled() {
|
|
452
|
+
try {
|
|
453
|
+
var stored = localStorage.getItem(AUTO_REFRESH_STORAGE_KEY);
|
|
454
|
+
return stored === null ? true : stored === 'true'; // default ON
|
|
455
|
+
} catch (e) {
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function setAutoRefreshEnabled(enabled) {
|
|
461
|
+
try { localStorage.setItem(AUTO_REFRESH_STORAGE_KEY, String(enabled)); } catch (e) { /* ignore */ }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function startAutoRefresh() {
|
|
465
|
+
stopAutoRefresh();
|
|
466
|
+
refreshIntervalId = setInterval(function () {
|
|
467
|
+
if (document.hidden) return; // don't poll a backend nobody's looking at
|
|
468
|
+
loadDashboard(currentDays);
|
|
469
|
+
}, REFRESH_INTERVAL_MS);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function stopAutoRefresh() {
|
|
473
|
+
if (refreshIntervalId) {
|
|
474
|
+
clearInterval(refreshIntervalId);
|
|
475
|
+
refreshIntervalId = null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function updateLastUpdatedText() {
|
|
480
|
+
var el = document.getElementById('lastUpdated');
|
|
481
|
+
if (!lastUpdatedAt) { el.textContent = ''; return; }
|
|
482
|
+
var seconds = Math.round((Date.now() - lastUpdatedAt) / 1000);
|
|
483
|
+
var text;
|
|
484
|
+
if (seconds < 5) text = 'Updated just now';
|
|
485
|
+
else if (seconds < 60) text = 'Updated ' + seconds + 's ago';
|
|
486
|
+
else text = 'Updated ' + Math.round(seconds / 60) + 'm ago';
|
|
487
|
+
el.textContent = text;
|
|
488
|
+
}
|
|
489
|
+
setInterval(updateLastUpdatedText, 1000);
|
|
490
|
+
|
|
491
|
+
var autoRefreshToggle = document.getElementById('autoRefreshToggle');
|
|
492
|
+
autoRefreshToggle.checked = isAutoRefreshEnabled();
|
|
493
|
+
if (autoRefreshToggle.checked) startAutoRefresh();
|
|
494
|
+
|
|
495
|
+
autoRefreshToggle.addEventListener('change', function () {
|
|
496
|
+
setAutoRefreshEnabled(autoRefreshToggle.checked);
|
|
497
|
+
if (autoRefreshToggle.checked) {
|
|
498
|
+
loadDashboard(currentDays); // refresh immediately on re-enable, don't wait a full interval
|
|
499
|
+
startAutoRefresh();
|
|
500
|
+
} else {
|
|
501
|
+
stopAutoRefresh();
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
// Coming back to a backgrounded tab is a natural moment to refresh,
|
|
506
|
+
// independent of the interval timer (which was skipping ticks while
|
|
507
|
+
// hidden anyway).
|
|
508
|
+
document.addEventListener('visibilitychange', function () {
|
|
509
|
+
if (!document.hidden && isAutoRefreshEnabled() && lastUpdatedAt) {
|
|
510
|
+
loadDashboard(currentDays);
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
document.querySelectorAll('.view-toggle button[data-toggle]').forEach(function (btn) {
|
|
515
|
+
btn.addEventListener('click', function () {
|
|
516
|
+
var id = btn.dataset.toggle;
|
|
517
|
+
viewMode[id] = viewMode[id] === 'table' ? 'chart' : 'table';
|
|
518
|
+
btn.textContent = viewMode[id] === 'table' ? 'View as chart' : 'View as table';
|
|
519
|
+
if (lastData) render(lastData);
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// ---------- rendering ----------
|
|
524
|
+
|
|
525
|
+
function render(data) {
|
|
526
|
+
renderKpis(data);
|
|
527
|
+
renderCostChart(data);
|
|
528
|
+
renderOutcomeChart(data);
|
|
529
|
+
renderProviderChart(data);
|
|
530
|
+
renderProviderTable(data);
|
|
531
|
+
renderProviderAlertsTable(data);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ---- Provider alerts table - one row per provider currently in
|
|
535
|
+
// trouble, one column per known failure reason (a checkmark in
|
|
536
|
+
// whichever column matches). The whole CARD is [hidden] when nothing's
|
|
537
|
+
// wrong - a healthy deployment shows nothing here at all instead of an
|
|
538
|
+
// always-visible table that's usually empty.
|
|
539
|
+
//
|
|
540
|
+
// "Currently" here matches provider_alerts's own source (server.js's
|
|
541
|
+
// /dashboard/data: metrics.providerStats(), a ROLLING window of recent
|
|
542
|
+
// requests, the same one router.js itself uses to decide routing
|
|
543
|
+
// health) - not this page's own calendar day-range picker. A provider
|
|
544
|
+
// that was broken 10 days ago but has been fine since won't show up
|
|
545
|
+
// here just because "Last 30 days" is selected; that's what the
|
|
546
|
+
// "Provider health" table right above this one is for.
|
|
547
|
+
var ALERT_COLUMNS = [
|
|
548
|
+
{ key: 'authentication_error', label: 'Authentication' },
|
|
549
|
+
{ key: 'insufficient_quota', label: 'Insufficient quota' },
|
|
550
|
+
{ key: 'rate_limit_error', label: 'Rate limit' }
|
|
551
|
+
];
|
|
552
|
+
|
|
553
|
+
function renderProviderAlertsTable(data) {
|
|
554
|
+
var card = document.getElementById('providerAlertsCard');
|
|
555
|
+
var container = document.getElementById('providerAlertsTable');
|
|
556
|
+
container.innerHTML = '';
|
|
557
|
+
var alerts = data.provider_alerts || [];
|
|
558
|
+
if (!alerts.length) {
|
|
559
|
+
card.hidden = true;
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
card.hidden = false;
|
|
563
|
+
|
|
564
|
+
var headers = ['Provider'].concat(ALERT_COLUMNS.map(function (c) { return c.label; }), ['Other']);
|
|
565
|
+
var rows = alerts.map(function (a) {
|
|
566
|
+
var row = [a.provider];
|
|
567
|
+
var matchedKnownColumn = false;
|
|
568
|
+
ALERT_COLUMNS.forEach(function (c) {
|
|
569
|
+
if (a.error_type === c.key) {
|
|
570
|
+
row.push('✓');
|
|
571
|
+
matchedKnownColumn = true;
|
|
572
|
+
} else {
|
|
573
|
+
row.push('');
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
// Whatever the provider itself called it, if it's not one of the
|
|
577
|
+
// three known buckets above - still shown, not silently dropped,
|
|
578
|
+
// same "unknown is more honest than guessing" reasoning as
|
|
579
|
+
// classifyErrorType() itself.
|
|
580
|
+
row.push(matchedKnownColumn ? '' : '✓ (' + a.error_type.replace(/_/g, ' ') + ')');
|
|
581
|
+
return row;
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
var table = buildTable(headers, rows, headers.map(function (_, i) { return i > 0; }));
|
|
585
|
+
// Mark every checkmark cell so it reads as a clear signal, not just
|
|
586
|
+
// more plain table text - matches the KPI tiles' own .critical
|
|
587
|
+
// treatment for "this number means trouble" elsewhere on this page.
|
|
588
|
+
Array.prototype.forEach.call(table.querySelectorAll('td.num'), function (td) {
|
|
589
|
+
if (td.textContent.indexOf('✓') !== -1) td.classList.add('alert-mark');
|
|
590
|
+
});
|
|
591
|
+
container.appendChild(table);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function renderKpis(data) {
|
|
595
|
+
var tiles = [
|
|
596
|
+
{ label: 'Total cost (' + data.days + 'd)', value: formatUsd(data.total_cost_usd), spark: data.daily.map(function (d) { return d.cost_usd; }) },
|
|
597
|
+
{ label: 'Saved (' + data.days + 'd)', value: formatUsd(data.saved_usd), spark: data.daily.map(function (d) { return d.saved_usd; }) },
|
|
598
|
+
{ label: 'Total requests', value: formatCount(data.sample_size) },
|
|
599
|
+
{ label: 'Exact hit rate', value: formatPercent(data.cache_hit_rate.exact) },
|
|
600
|
+
{ label: 'Semantic hit rate', value: formatPercent(data.cache_hit_rate.semantic) },
|
|
601
|
+
{ label: 'Combined hit rate', value: formatPercent(data.cache_hit_rate.combined) },
|
|
602
|
+
{ label: 'Error rate', value: formatPercent(data.error_rate), critical: data.error_rate > 0.05 }
|
|
603
|
+
];
|
|
604
|
+
var row = document.getElementById('kpiRow');
|
|
605
|
+
row.innerHTML = '';
|
|
606
|
+
tiles.forEach(function (t) {
|
|
607
|
+
var tile = document.createElement('div');
|
|
608
|
+
tile.className = 'stat-tile';
|
|
609
|
+
var label = document.createElement('div');
|
|
610
|
+
label.className = 'label';
|
|
611
|
+
label.textContent = t.label;
|
|
612
|
+
var value = document.createElement('div');
|
|
613
|
+
value.className = 'value' + (t.critical ? ' critical' : '');
|
|
614
|
+
value.textContent = t.value;
|
|
615
|
+
tile.appendChild(label);
|
|
616
|
+
tile.appendChild(value);
|
|
617
|
+
if (t.spark && t.spark.length > 1) {
|
|
618
|
+
tile.appendChild(sparkline(t.spark));
|
|
619
|
+
}
|
|
620
|
+
row.appendChild(tile);
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function sparkline(values) {
|
|
625
|
+
var w = 140, h = 28;
|
|
626
|
+
var max = Math.max.apply(null, values.concat([0.0001]));
|
|
627
|
+
var min = Math.min.apply(null, values.concat([0]));
|
|
628
|
+
var range = (max - min) || 1;
|
|
629
|
+
var svg = document.createElementNS(SVG_NS, 'svg');
|
|
630
|
+
svg.setAttribute('class', 'spark');
|
|
631
|
+
svg.setAttribute('width', w);
|
|
632
|
+
svg.setAttribute('height', h);
|
|
633
|
+
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
|
|
634
|
+
var step = values.length > 1 ? w / (values.length - 1) : 0;
|
|
635
|
+
var points = values.map(function (v, i) {
|
|
636
|
+
var x = i * step;
|
|
637
|
+
var y = h - 4 - ((v - min) / range) * (h - 8);
|
|
638
|
+
return [x, y];
|
|
639
|
+
});
|
|
640
|
+
var path = document.createElementNS(SVG_NS, 'path');
|
|
641
|
+
var d = points.map(function (p, i) { return (i === 0 ? 'M' : 'L') + p[0].toFixed(1) + ',' + p[1].toFixed(1); }).join(' ');
|
|
642
|
+
path.setAttribute('d', d);
|
|
643
|
+
path.setAttribute('fill', 'none');
|
|
644
|
+
path.setAttribute('stroke', 'var(--series-1)');
|
|
645
|
+
path.setAttribute('stroke-width', '2');
|
|
646
|
+
path.setAttribute('stroke-linejoin', 'round');
|
|
647
|
+
path.setAttribute('stroke-linecap', 'round');
|
|
648
|
+
svg.appendChild(path);
|
|
649
|
+
var last = points[points.length - 1];
|
|
650
|
+
var dot = document.createElementNS(SVG_NS, 'circle');
|
|
651
|
+
dot.setAttribute('cx', last[0]);
|
|
652
|
+
dot.setAttribute('cy', last[1]);
|
|
653
|
+
dot.setAttribute('r', 3);
|
|
654
|
+
dot.setAttribute('fill', 'var(--series-1)');
|
|
655
|
+
svg.appendChild(dot);
|
|
656
|
+
return svg;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function ensureTooltip(container) {
|
|
660
|
+
var tip = container.querySelector('.tooltip');
|
|
661
|
+
if (!tip) {
|
|
662
|
+
tip = document.createElement('div');
|
|
663
|
+
tip.className = 'tooltip';
|
|
664
|
+
container.appendChild(tip);
|
|
665
|
+
}
|
|
666
|
+
return tip;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function positionTooltip(tip, container, x, y) {
|
|
670
|
+
var rect = container.getBoundingClientRect();
|
|
671
|
+
var tipWidth = tip.offsetWidth || 140;
|
|
672
|
+
var left = Math.min(Math.max(x + 12, 4), rect.width - tipWidth - 4);
|
|
673
|
+
tip.style.left = left + 'px';
|
|
674
|
+
tip.style.top = Math.max(y - 12, 4) + 'px';
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function setTooltipRows(tip, title, rows, note) {
|
|
678
|
+
tip.textContent = '';
|
|
679
|
+
var titleEl = document.createElement('div');
|
|
680
|
+
titleEl.className = 't-title';
|
|
681
|
+
titleEl.textContent = title;
|
|
682
|
+
tip.appendChild(titleEl);
|
|
683
|
+
rows.forEach(function (r) {
|
|
684
|
+
var rowEl = document.createElement('div');
|
|
685
|
+
rowEl.className = 't-row';
|
|
686
|
+
var key = document.createElement('span');
|
|
687
|
+
key.className = 't-key';
|
|
688
|
+
key.style.background = r.color;
|
|
689
|
+
var label = document.createElement('span');
|
|
690
|
+
label.textContent = r.label + ':';
|
|
691
|
+
var value = document.createElement('span');
|
|
692
|
+
value.className = 't-value';
|
|
693
|
+
value.textContent = r.value;
|
|
694
|
+
rowEl.appendChild(key);
|
|
695
|
+
rowEl.appendChild(label);
|
|
696
|
+
rowEl.appendChild(value);
|
|
697
|
+
tip.appendChild(rowEl);
|
|
698
|
+
});
|
|
699
|
+
if (note) {
|
|
700
|
+
var noteEl = document.createElement('div');
|
|
701
|
+
noteEl.className = 't-note';
|
|
702
|
+
noteEl.textContent = note;
|
|
703
|
+
tip.appendChild(noteEl);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ---- Cost over time: single-series line chart ----
|
|
708
|
+
|
|
709
|
+
function renderCostChart(data) {
|
|
710
|
+
var container = document.getElementById('costChart');
|
|
711
|
+
container.innerHTML = '';
|
|
712
|
+
|
|
713
|
+
if (!data.daily.length) {
|
|
714
|
+
container.innerHTML = '<div class="empty-note">No requests recorded in this range yet.</div>';
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (viewMode.costChart === 'table') {
|
|
719
|
+
container.appendChild(buildTable(
|
|
720
|
+
['Date', 'Cost'],
|
|
721
|
+
data.daily.map(function (d) { return [formatDateLabel(d.date), formatUsd(d.cost_usd)]; }),
|
|
722
|
+
[false, true]
|
|
723
|
+
));
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
var w = 760, h = 220, padL = 46, padR = 16, padT = 16, padB = 28;
|
|
728
|
+
var plotW = w - padL - padR, plotH = h - padT - padB;
|
|
729
|
+
var values = data.daily.map(function (d) { return d.cost_usd; });
|
|
730
|
+
var rawMax = Math.max.apply(null, values.concat([0.0001]));
|
|
731
|
+
var axis = niceAxisTicks(rawMax);
|
|
732
|
+
var maxV = axis.max;
|
|
733
|
+
|
|
734
|
+
var svg = document.createElementNS(SVG_NS, 'svg');
|
|
735
|
+
svg.setAttribute('class', 'chart');
|
|
736
|
+
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
|
|
737
|
+
|
|
738
|
+
// gridlines + y-axis labels, at clean ("nice") values, not at
|
|
739
|
+
// whatever fraction of the raw max a fixed step count produces.
|
|
740
|
+
axis.ticks.forEach(function (tickValue) {
|
|
741
|
+
var gy = padT + plotH - (tickValue / maxV) * plotH;
|
|
742
|
+
var line = document.createElementNS(SVG_NS, 'line');
|
|
743
|
+
line.setAttribute('class', 'gridline');
|
|
744
|
+
line.setAttribute('x1', padL); line.setAttribute('x2', w - padR);
|
|
745
|
+
line.setAttribute('y1', gy); line.setAttribute('y2', gy);
|
|
746
|
+
svg.appendChild(line);
|
|
747
|
+
var label = document.createElementNS(SVG_NS, 'text');
|
|
748
|
+
label.setAttribute('class', 'axis-label');
|
|
749
|
+
label.setAttribute('x', padL - 8);
|
|
750
|
+
label.setAttribute('y', gy + 4);
|
|
751
|
+
label.setAttribute('text-anchor', 'end');
|
|
752
|
+
label.textContent = formatUsd(tickValue);
|
|
753
|
+
svg.appendChild(label);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
var stepX = data.daily.length > 1 ? plotW / (data.daily.length - 1) : 0;
|
|
757
|
+
var points = data.daily.map(function (d, i) {
|
|
758
|
+
var x = padL + i * stepX;
|
|
759
|
+
var y = padT + plotH - (d.cost_usd / maxV) * plotH;
|
|
760
|
+
return { x: x, y: y, d: d };
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
// x-axis date labels: first, middle, last only (avoid crowding)
|
|
764
|
+
[0, Math.floor((points.length - 1) / 2), points.length - 1].forEach(function (i, idx, arr) {
|
|
765
|
+
if (idx > 0 && arr[idx] === arr[idx - 1]) return;
|
|
766
|
+
var label = document.createElementNS(SVG_NS, 'text');
|
|
767
|
+
label.setAttribute('class', 'axis-label');
|
|
768
|
+
label.setAttribute('x', points[i].x);
|
|
769
|
+
label.setAttribute('y', h - 6);
|
|
770
|
+
label.setAttribute('text-anchor', idx === 0 ? 'start' : (idx === arr.length - 1 ? 'end' : 'middle'));
|
|
771
|
+
label.textContent = formatDateLabel(points[i].d.date);
|
|
772
|
+
svg.appendChild(label);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
var path = document.createElementNS(SVG_NS, 'path');
|
|
776
|
+
var dAttr = points.map(function (p, i) { return (i === 0 ? 'M' : 'L') + p.x.toFixed(1) + ',' + p.y.toFixed(1); }).join(' ');
|
|
777
|
+
path.setAttribute('d', dAttr);
|
|
778
|
+
path.setAttribute('fill', 'none');
|
|
779
|
+
path.setAttribute('stroke', 'var(--series-1)');
|
|
780
|
+
path.setAttribute('stroke-width', '2');
|
|
781
|
+
path.setAttribute('stroke-linejoin', 'round');
|
|
782
|
+
path.setAttribute('stroke-linecap', 'round');
|
|
783
|
+
svg.appendChild(path);
|
|
784
|
+
|
|
785
|
+
// end-dot + direct label on the last point
|
|
786
|
+
var lastP = points[points.length - 1];
|
|
787
|
+
var endDot = document.createElementNS(SVG_NS, 'circle');
|
|
788
|
+
endDot.setAttribute('cx', lastP.x); endDot.setAttribute('cy', lastP.y);
|
|
789
|
+
endDot.setAttribute('r', 4);
|
|
790
|
+
endDot.setAttribute('fill', 'var(--series-1)');
|
|
791
|
+
endDot.setAttribute('stroke', 'var(--surface-1)');
|
|
792
|
+
endDot.setAttribute('stroke-width', '2');
|
|
793
|
+
svg.appendChild(endDot);
|
|
794
|
+
var endLabel = document.createElementNS(SVG_NS, 'text');
|
|
795
|
+
endLabel.setAttribute('class', 'direct-label');
|
|
796
|
+
endLabel.setAttribute('x', Math.min(lastP.x + 8, w - padR - 40));
|
|
797
|
+
endLabel.setAttribute('y', lastP.y - 8);
|
|
798
|
+
endLabel.textContent = formatUsd(lastP.d.cost_usd);
|
|
799
|
+
svg.appendChild(endLabel);
|
|
800
|
+
|
|
801
|
+
// crosshair
|
|
802
|
+
var crosshair = document.createElementNS(SVG_NS, 'line');
|
|
803
|
+
crosshair.setAttribute('class', 'axis-line');
|
|
804
|
+
crosshair.setAttribute('y1', padT); crosshair.setAttribute('y2', padT + plotH);
|
|
805
|
+
crosshair.setAttribute('stroke-dasharray', 'none');
|
|
806
|
+
crosshair.style.opacity = '0';
|
|
807
|
+
svg.appendChild(crosshair);
|
|
808
|
+
|
|
809
|
+
var hitLayer = document.createElementNS(SVG_NS, 'rect');
|
|
810
|
+
hitLayer.setAttribute('x', padL); hitLayer.setAttribute('y', padT);
|
|
811
|
+
hitLayer.setAttribute('width', plotW); hitLayer.setAttribute('height', plotH);
|
|
812
|
+
hitLayer.setAttribute('fill', 'transparent');
|
|
813
|
+
svg.appendChild(hitLayer);
|
|
814
|
+
|
|
815
|
+
container.appendChild(svg);
|
|
816
|
+
var tip = ensureTooltip(container);
|
|
817
|
+
|
|
818
|
+
function handleMove(clientX, clientY) {
|
|
819
|
+
var svgRect = svg.getBoundingClientRect();
|
|
820
|
+
var scaleX = w / svgRect.width;
|
|
821
|
+
var localX = (clientX - svgRect.left) * scaleX;
|
|
822
|
+
var idx = Math.round((localX - padL) / (stepX || 1));
|
|
823
|
+
idx = Math.max(0, Math.min(points.length - 1, idx));
|
|
824
|
+
var p = points[idx];
|
|
825
|
+
crosshair.setAttribute('x1', p.x); crosshair.setAttribute('x2', p.x);
|
|
826
|
+
crosshair.style.opacity = '1';
|
|
827
|
+
setTooltipRows(tip, formatDateLabel(p.d.date), [
|
|
828
|
+
{ color: 'var(--series-1)', label: 'Cost', value: formatUsd(p.d.cost_usd) }
|
|
829
|
+
]);
|
|
830
|
+
tip.classList.add('visible');
|
|
831
|
+
var localY = (clientY - svgRect.top) * (h / svgRect.height);
|
|
832
|
+
positionTooltip(tip, container, (p.x / w) * svgRect.width, (p.y / h) * svgRect.height);
|
|
833
|
+
}
|
|
834
|
+
hitLayer.addEventListener('pointermove', function (e) { handleMove(e.clientX, e.clientY); });
|
|
835
|
+
hitLayer.addEventListener('pointerleave', function () {
|
|
836
|
+
crosshair.style.opacity = '0';
|
|
837
|
+
tip.classList.remove('visible');
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ---- Requests by outcome: stacked bar chart ----
|
|
842
|
+
|
|
843
|
+
function renderOutcomeChart(data) {
|
|
844
|
+
var container = document.getElementById('outcomeChart');
|
|
845
|
+
var legend = document.getElementById('outcomeLegend');
|
|
846
|
+
container.innerHTML = '';
|
|
847
|
+
|
|
848
|
+
var series = [
|
|
849
|
+
{ key: 'exact_hits', label: 'Exact hit', color: 'var(--series-1)' },
|
|
850
|
+
{ key: 'semantic_hits', label: 'Semantic hit', color: 'var(--series-2)' },
|
|
851
|
+
{ key: 'misses', label: 'Miss (dispatched)', color: 'var(--series-3)' }
|
|
852
|
+
];
|
|
853
|
+
|
|
854
|
+
legend.innerHTML = '';
|
|
855
|
+
series.forEach(function (s) {
|
|
856
|
+
var item = document.createElement('span');
|
|
857
|
+
item.className = 'item';
|
|
858
|
+
var sw = document.createElement('span');
|
|
859
|
+
sw.className = 'swatch';
|
|
860
|
+
sw.style.background = s.color;
|
|
861
|
+
var label = document.createElement('span');
|
|
862
|
+
label.textContent = s.label;
|
|
863
|
+
item.appendChild(sw); item.appendChild(label);
|
|
864
|
+
legend.appendChild(item);
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
if (!data.daily.length) {
|
|
868
|
+
container.innerHTML = '<div class="empty-note">No requests recorded in this range yet.</div>';
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (viewMode.outcomeChart === 'table') {
|
|
873
|
+
container.appendChild(buildTable(
|
|
874
|
+
['Date', 'Exact hit', 'Semantic hit', 'Miss', 'Errors', 'Total'],
|
|
875
|
+
data.daily.map(function (d) {
|
|
876
|
+
return [formatDateLabel(d.date), formatCount(d.exact_hits), formatCount(d.semantic_hits), formatCount(d.misses), formatCount(d.errors), formatCount(d.requests)];
|
|
877
|
+
}),
|
|
878
|
+
[false, true, true, true, true, true]
|
|
879
|
+
));
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
var w = 760, h = 220, padL = 40, padR = 16, padT = 16, padB = 28;
|
|
884
|
+
var plotW = w - padL - padR, plotH = h - padT - padB;
|
|
885
|
+
var rawMaxTotal = Math.max.apply(null, data.daily.map(function (d) { return d.requests; }).concat([1]));
|
|
886
|
+
var axis = niceAxisTicks(rawMaxTotal);
|
|
887
|
+
var maxTotal = axis.max;
|
|
888
|
+
|
|
889
|
+
var svg = document.createElementNS(SVG_NS, 'svg');
|
|
890
|
+
svg.setAttribute('class', 'chart');
|
|
891
|
+
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
|
|
892
|
+
|
|
893
|
+
axis.ticks.forEach(function (tickValue) {
|
|
894
|
+
var gy = padT + plotH - (tickValue / maxTotal) * plotH;
|
|
895
|
+
var line = document.createElementNS(SVG_NS, 'line');
|
|
896
|
+
line.setAttribute('class', 'gridline');
|
|
897
|
+
line.setAttribute('x1', padL); line.setAttribute('x2', w - padR);
|
|
898
|
+
line.setAttribute('y1', gy); line.setAttribute('y2', gy);
|
|
899
|
+
svg.appendChild(line);
|
|
900
|
+
var label = document.createElementNS(SVG_NS, 'text');
|
|
901
|
+
label.setAttribute('class', 'axis-label');
|
|
902
|
+
label.setAttribute('x', padL - 6);
|
|
903
|
+
label.setAttribute('y', gy + 4);
|
|
904
|
+
label.setAttribute('text-anchor', 'end');
|
|
905
|
+
label.textContent = formatCount(Math.round(tickValue));
|
|
906
|
+
svg.appendChild(label);
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
var n = data.daily.length;
|
|
910
|
+
var slotW = plotW / n;
|
|
911
|
+
var barW = Math.min(24, slotW * 0.6);
|
|
912
|
+
var gap = 2;
|
|
913
|
+
|
|
914
|
+
container.appendChild(svg);
|
|
915
|
+
var tip = ensureTooltip(container);
|
|
916
|
+
|
|
917
|
+
data.daily.forEach(function (d, i) {
|
|
918
|
+
var slotX = padL + i * slotW + (slotW - barW) / 2;
|
|
919
|
+
var yCursor = padT + plotH;
|
|
920
|
+
var segs = [
|
|
921
|
+
{ value: d.exact_hits, color: 'var(--series-1)' },
|
|
922
|
+
{ value: d.semantic_hits, color: 'var(--series-2)' },
|
|
923
|
+
{ value: d.misses, color: 'var(--series-3)' }
|
|
924
|
+
];
|
|
925
|
+
var group = document.createElementNS(SVG_NS, 'g');
|
|
926
|
+
segs.forEach(function (seg) {
|
|
927
|
+
if (seg.value <= 0) return;
|
|
928
|
+
var segH = (seg.value / maxTotal) * plotH;
|
|
929
|
+
var rect = document.createElementNS(SVG_NS, 'rect');
|
|
930
|
+
rect.setAttribute('x', slotX);
|
|
931
|
+
rect.setAttribute('y', yCursor - segH);
|
|
932
|
+
rect.setAttribute('width', barW);
|
|
933
|
+
rect.setAttribute('height', Math.max(segH - gap, 0));
|
|
934
|
+
rect.setAttribute('rx', 2);
|
|
935
|
+
rect.setAttribute('fill', seg.color);
|
|
936
|
+
group.appendChild(rect);
|
|
937
|
+
yCursor -= segH;
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
var hitRect = document.createElementNS(SVG_NS, 'rect');
|
|
941
|
+
hitRect.setAttribute('x', padL + i * slotW);
|
|
942
|
+
hitRect.setAttribute('y', padT);
|
|
943
|
+
hitRect.setAttribute('width', slotW);
|
|
944
|
+
hitRect.setAttribute('height', plotH);
|
|
945
|
+
hitRect.setAttribute('fill', 'transparent');
|
|
946
|
+
hitRect.addEventListener('pointermove', function (e) {
|
|
947
|
+
var svgRect = svg.getBoundingClientRect();
|
|
948
|
+
var rows = [
|
|
949
|
+
{ color: 'var(--series-1)', label: 'Exact hit', value: formatCount(d.exact_hits) },
|
|
950
|
+
{ color: 'var(--series-2)', label: 'Semantic hit', value: formatCount(d.semantic_hits) },
|
|
951
|
+
{ color: 'var(--series-3)', label: 'Miss', value: formatCount(d.misses) }
|
|
952
|
+
];
|
|
953
|
+
var note = d.errors > 0 ? (d.errors + ' of the misses errored') : null;
|
|
954
|
+
setTooltipRows(tip, formatDateLabel(d.date) + ' · ' + formatCount(d.requests) + ' requests', rows, note);
|
|
955
|
+
tip.classList.add('visible');
|
|
956
|
+
positionTooltip(tip, container, (e.clientX - svgRect.left), (e.clientY - svgRect.top));
|
|
957
|
+
});
|
|
958
|
+
hitRect.addEventListener('pointerleave', function () { tip.classList.remove('visible'); });
|
|
959
|
+
group.appendChild(hitRect);
|
|
960
|
+
|
|
961
|
+
if (i === 0 || i === n - 1 || i === Math.floor((n - 1) / 2)) {
|
|
962
|
+
var label = document.createElementNS(SVG_NS, 'text');
|
|
963
|
+
label.setAttribute('class', 'axis-label');
|
|
964
|
+
label.setAttribute('x', padL + i * slotW + slotW / 2);
|
|
965
|
+
label.setAttribute('y', h - 6);
|
|
966
|
+
label.setAttribute('text-anchor', 'middle');
|
|
967
|
+
label.textContent = formatDateLabel(d.date);
|
|
968
|
+
svg.appendChild(label);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
svg.appendChild(group);
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// ---- Cost by provider: simple bar chart ----
|
|
976
|
+
|
|
977
|
+
function renderProviderChart(data) {
|
|
978
|
+
var container = document.getElementById('providerChart');
|
|
979
|
+
container.innerHTML = '';
|
|
980
|
+
|
|
981
|
+
var names = Object.keys(data.by_provider).sort();
|
|
982
|
+
if (!names.length) {
|
|
983
|
+
container.innerHTML = '<div class="empty-note">No provider activity in this range yet.</div>';
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
if (viewMode.providerChart === 'table') {
|
|
988
|
+
container.appendChild(buildTable(
|
|
989
|
+
['Provider', 'Cost'],
|
|
990
|
+
names.map(function (name) { return [name, formatUsd(data.by_provider[name].cost_usd)]; }),
|
|
991
|
+
[false, true]
|
|
992
|
+
));
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
var colors = ['var(--series-1)', 'var(--series-2)', 'var(--series-3)'];
|
|
997
|
+
var w = 760, h = 160, padL = 90, padR = 60, padT = 10, padB = 10;
|
|
998
|
+
var plotW = w - padL - padR, plotH = h - padT - padB;
|
|
999
|
+
var maxCost = Math.max.apply(null, names.map(function (n) { return data.by_provider[n].cost_usd; }).concat([0.0001]));
|
|
1000
|
+
var barH = 22, gap = (plotH - names.length * barH) / Math.max(names.length - 1, 1);
|
|
1001
|
+
|
|
1002
|
+
var svg = document.createElementNS(SVG_NS, 'svg');
|
|
1003
|
+
svg.setAttribute('class', 'chart');
|
|
1004
|
+
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + Math.max(h, names.length * (barH + 10)));
|
|
1005
|
+
|
|
1006
|
+
container.appendChild(svg);
|
|
1007
|
+
var tip = ensureTooltip(container);
|
|
1008
|
+
|
|
1009
|
+
names.forEach(function (name, i) {
|
|
1010
|
+
var cost = data.by_provider[name].cost_usd;
|
|
1011
|
+
var y = padT + i * (barH + 10);
|
|
1012
|
+
var barLen = (cost / maxCost) * plotW;
|
|
1013
|
+
var color = colors[i % colors.length];
|
|
1014
|
+
|
|
1015
|
+
var label = document.createElementNS(SVG_NS, 'text');
|
|
1016
|
+
label.setAttribute('class', 'axis-label');
|
|
1017
|
+
label.setAttribute('x', padL - 8);
|
|
1018
|
+
label.setAttribute('y', y + barH / 2 + 4);
|
|
1019
|
+
label.setAttribute('text-anchor', 'end');
|
|
1020
|
+
label.textContent = name;
|
|
1021
|
+
svg.appendChild(label);
|
|
1022
|
+
|
|
1023
|
+
var rect = document.createElementNS(SVG_NS, 'rect');
|
|
1024
|
+
rect.setAttribute('x', padL);
|
|
1025
|
+
rect.setAttribute('y', y);
|
|
1026
|
+
rect.setAttribute('width', Math.max(barLen, 2));
|
|
1027
|
+
rect.setAttribute('height', barH);
|
|
1028
|
+
rect.setAttribute('rx', 4);
|
|
1029
|
+
rect.setAttribute('fill', color);
|
|
1030
|
+
svg.appendChild(rect);
|
|
1031
|
+
|
|
1032
|
+
var valueLabel = document.createElementNS(SVG_NS, 'text');
|
|
1033
|
+
valueLabel.setAttribute('class', 'direct-label');
|
|
1034
|
+
valueLabel.setAttribute('x', padL + barLen + 8);
|
|
1035
|
+
valueLabel.setAttribute('y', y + barH / 2 + 4);
|
|
1036
|
+
valueLabel.textContent = formatUsd(cost);
|
|
1037
|
+
svg.appendChild(valueLabel);
|
|
1038
|
+
|
|
1039
|
+
var hitRect = document.createElementNS(SVG_NS, 'rect');
|
|
1040
|
+
hitRect.setAttribute('x', padL);
|
|
1041
|
+
hitRect.setAttribute('y', y);
|
|
1042
|
+
hitRect.setAttribute('width', plotW);
|
|
1043
|
+
hitRect.setAttribute('height', barH);
|
|
1044
|
+
hitRect.setAttribute('fill', 'transparent');
|
|
1045
|
+
hitRect.addEventListener('pointermove', function (e) {
|
|
1046
|
+
var svgRect = svg.getBoundingClientRect();
|
|
1047
|
+
setTooltipRows(tip, name, [
|
|
1048
|
+
{ color: color, label: 'Cost', value: formatUsd(cost) },
|
|
1049
|
+
{ color: color, label: 'Requests', value: formatCount(data.by_provider[name].requests) }
|
|
1050
|
+
]);
|
|
1051
|
+
tip.classList.add('visible');
|
|
1052
|
+
positionTooltip(tip, container, (e.clientX - svgRect.left), (e.clientY - svgRect.top));
|
|
1053
|
+
});
|
|
1054
|
+
hitRect.addEventListener('pointerleave', function () { tip.classList.remove('visible'); });
|
|
1055
|
+
svg.appendChild(hitRect);
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// ---- Provider health table ----
|
|
1060
|
+
|
|
1061
|
+
function renderProviderTable(data) {
|
|
1062
|
+
var container = document.getElementById('providerTable');
|
|
1063
|
+
container.innerHTML = '';
|
|
1064
|
+
var names = Object.keys(data.by_provider).sort();
|
|
1065
|
+
if (!names.length) {
|
|
1066
|
+
container.innerHTML = '<div class="empty-note">No provider activity in this range yet.</div>';
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
container.appendChild(buildTable(
|
|
1070
|
+
['Provider', 'Requests', 'Cost', 'Error rate', 'Avg latency'],
|
|
1071
|
+
names.map(function (name) {
|
|
1072
|
+
var p = data.by_provider[name];
|
|
1073
|
+
return [name, formatCount(p.requests), formatUsd(p.cost_usd), formatPercent(p.errorRate), formatMs(p.avgLatencyMs)];
|
|
1074
|
+
}),
|
|
1075
|
+
[false, true, true, true, true]
|
|
1076
|
+
));
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// ---- shared table builder (the accessible twin of every chart) ----
|
|
1080
|
+
|
|
1081
|
+
function buildTable(headers, rows, numericCols) {
|
|
1082
|
+
var table = document.createElement('table');
|
|
1083
|
+
table.className = 'data-table';
|
|
1084
|
+
var thead = document.createElement('thead');
|
|
1085
|
+
var headRow = document.createElement('tr');
|
|
1086
|
+
headers.forEach(function (hLabel, i) {
|
|
1087
|
+
var th = document.createElement('th');
|
|
1088
|
+
if (numericCols && numericCols[i]) th.className = 'num';
|
|
1089
|
+
th.textContent = hLabel;
|
|
1090
|
+
headRow.appendChild(th);
|
|
1091
|
+
});
|
|
1092
|
+
thead.appendChild(headRow);
|
|
1093
|
+
table.appendChild(thead);
|
|
1094
|
+
var tbody = document.createElement('tbody');
|
|
1095
|
+
rows.forEach(function (row) {
|
|
1096
|
+
var tr = document.createElement('tr');
|
|
1097
|
+
row.forEach(function (cell, i) {
|
|
1098
|
+
var td = document.createElement('td');
|
|
1099
|
+
if (numericCols && numericCols[i]) td.className = 'num';
|
|
1100
|
+
td.textContent = cell;
|
|
1101
|
+
tr.appendChild(td);
|
|
1102
|
+
});
|
|
1103
|
+
tbody.appendChild(tr);
|
|
1104
|
+
});
|
|
1105
|
+
table.appendChild(tbody);
|
|
1106
|
+
return table;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// ---------- boot ----------
|
|
1110
|
+
|
|
1111
|
+
if (getStoredKey()) {
|
|
1112
|
+
loadDashboard(currentDays);
|
|
1113
|
+
} else {
|
|
1114
|
+
showKeyGate('');
|
|
1115
|
+
}
|
|
1116
|
+
})();
|
|
1117
|
+
</script>
|
|
1118
|
+
|
|
1119
|
+
</body>
|
|
1120
|
+
</html>
|