cachegate 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1119 +1,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: 'Total requests', value: formatCount(data.sample_size) },
598
- { label: 'Exact hit rate', value: formatPercent(data.cache_hit_rate.exact) },
599
- { label: 'Semantic hit rate', value: formatPercent(data.cache_hit_rate.semantic) },
600
- { label: 'Combined hit rate', value: formatPercent(data.cache_hit_rate.combined) },
601
- { label: 'Error rate', value: formatPercent(data.error_rate), critical: data.error_rate > 0.05 }
602
- ];
603
- var row = document.getElementById('kpiRow');
604
- row.innerHTML = '';
605
- tiles.forEach(function (t) {
606
- var tile = document.createElement('div');
607
- tile.className = 'stat-tile';
608
- var label = document.createElement('div');
609
- label.className = 'label';
610
- label.textContent = t.label;
611
- var value = document.createElement('div');
612
- value.className = 'value' + (t.critical ? ' critical' : '');
613
- value.textContent = t.value;
614
- tile.appendChild(label);
615
- tile.appendChild(value);
616
- if (t.spark && t.spark.length > 1) {
617
- tile.appendChild(sparkline(t.spark));
618
- }
619
- row.appendChild(tile);
620
- });
621
- }
622
-
623
- function sparkline(values) {
624
- var w = 140, h = 28;
625
- var max = Math.max.apply(null, values.concat([0.0001]));
626
- var min = Math.min.apply(null, values.concat([0]));
627
- var range = (max - min) || 1;
628
- var svg = document.createElementNS(SVG_NS, 'svg');
629
- svg.setAttribute('class', 'spark');
630
- svg.setAttribute('width', w);
631
- svg.setAttribute('height', h);
632
- svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
633
- var step = values.length > 1 ? w / (values.length - 1) : 0;
634
- var points = values.map(function (v, i) {
635
- var x = i * step;
636
- var y = h - 4 - ((v - min) / range) * (h - 8);
637
- return [x, y];
638
- });
639
- var path = document.createElementNS(SVG_NS, 'path');
640
- var d = points.map(function (p, i) { return (i === 0 ? 'M' : 'L') + p[0].toFixed(1) + ',' + p[1].toFixed(1); }).join(' ');
641
- path.setAttribute('d', d);
642
- path.setAttribute('fill', 'none');
643
- path.setAttribute('stroke', 'var(--series-1)');
644
- path.setAttribute('stroke-width', '2');
645
- path.setAttribute('stroke-linejoin', 'round');
646
- path.setAttribute('stroke-linecap', 'round');
647
- svg.appendChild(path);
648
- var last = points[points.length - 1];
649
- var dot = document.createElementNS(SVG_NS, 'circle');
650
- dot.setAttribute('cx', last[0]);
651
- dot.setAttribute('cy', last[1]);
652
- dot.setAttribute('r', 3);
653
- dot.setAttribute('fill', 'var(--series-1)');
654
- svg.appendChild(dot);
655
- return svg;
656
- }
657
-
658
- function ensureTooltip(container) {
659
- var tip = container.querySelector('.tooltip');
660
- if (!tip) {
661
- tip = document.createElement('div');
662
- tip.className = 'tooltip';
663
- container.appendChild(tip);
664
- }
665
- return tip;
666
- }
667
-
668
- function positionTooltip(tip, container, x, y) {
669
- var rect = container.getBoundingClientRect();
670
- var tipWidth = tip.offsetWidth || 140;
671
- var left = Math.min(Math.max(x + 12, 4), rect.width - tipWidth - 4);
672
- tip.style.left = left + 'px';
673
- tip.style.top = Math.max(y - 12, 4) + 'px';
674
- }
675
-
676
- function setTooltipRows(tip, title, rows, note) {
677
- tip.textContent = '';
678
- var titleEl = document.createElement('div');
679
- titleEl.className = 't-title';
680
- titleEl.textContent = title;
681
- tip.appendChild(titleEl);
682
- rows.forEach(function (r) {
683
- var rowEl = document.createElement('div');
684
- rowEl.className = 't-row';
685
- var key = document.createElement('span');
686
- key.className = 't-key';
687
- key.style.background = r.color;
688
- var label = document.createElement('span');
689
- label.textContent = r.label + ':';
690
- var value = document.createElement('span');
691
- value.className = 't-value';
692
- value.textContent = r.value;
693
- rowEl.appendChild(key);
694
- rowEl.appendChild(label);
695
- rowEl.appendChild(value);
696
- tip.appendChild(rowEl);
697
- });
698
- if (note) {
699
- var noteEl = document.createElement('div');
700
- noteEl.className = 't-note';
701
- noteEl.textContent = note;
702
- tip.appendChild(noteEl);
703
- }
704
- }
705
-
706
- // ---- Cost over time: single-series line chart ----
707
-
708
- function renderCostChart(data) {
709
- var container = document.getElementById('costChart');
710
- container.innerHTML = '';
711
-
712
- if (!data.daily.length) {
713
- container.innerHTML = '<div class="empty-note">No requests recorded in this range yet.</div>';
714
- return;
715
- }
716
-
717
- if (viewMode.costChart === 'table') {
718
- container.appendChild(buildTable(
719
- ['Date', 'Cost'],
720
- data.daily.map(function (d) { return [formatDateLabel(d.date), formatUsd(d.cost_usd)]; }),
721
- [false, true]
722
- ));
723
- return;
724
- }
725
-
726
- var w = 760, h = 220, padL = 46, padR = 16, padT = 16, padB = 28;
727
- var plotW = w - padL - padR, plotH = h - padT - padB;
728
- var values = data.daily.map(function (d) { return d.cost_usd; });
729
- var rawMax = Math.max.apply(null, values.concat([0.0001]));
730
- var axis = niceAxisTicks(rawMax);
731
- var maxV = axis.max;
732
-
733
- var svg = document.createElementNS(SVG_NS, 'svg');
734
- svg.setAttribute('class', 'chart');
735
- svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
736
-
737
- // gridlines + y-axis labels, at clean ("nice") values, not at
738
- // whatever fraction of the raw max a fixed step count produces.
739
- axis.ticks.forEach(function (tickValue) {
740
- var gy = padT + plotH - (tickValue / maxV) * plotH;
741
- var line = document.createElementNS(SVG_NS, 'line');
742
- line.setAttribute('class', 'gridline');
743
- line.setAttribute('x1', padL); line.setAttribute('x2', w - padR);
744
- line.setAttribute('y1', gy); line.setAttribute('y2', gy);
745
- svg.appendChild(line);
746
- var label = document.createElementNS(SVG_NS, 'text');
747
- label.setAttribute('class', 'axis-label');
748
- label.setAttribute('x', padL - 8);
749
- label.setAttribute('y', gy + 4);
750
- label.setAttribute('text-anchor', 'end');
751
- label.textContent = formatUsd(tickValue);
752
- svg.appendChild(label);
753
- });
754
-
755
- var stepX = data.daily.length > 1 ? plotW / (data.daily.length - 1) : 0;
756
- var points = data.daily.map(function (d, i) {
757
- var x = padL + i * stepX;
758
- var y = padT + plotH - (d.cost_usd / maxV) * plotH;
759
- return { x: x, y: y, d: d };
760
- });
761
-
762
- // x-axis date labels: first, middle, last only (avoid crowding)
763
- [0, Math.floor((points.length - 1) / 2), points.length - 1].forEach(function (i, idx, arr) {
764
- if (idx > 0 && arr[idx] === arr[idx - 1]) return;
765
- var label = document.createElementNS(SVG_NS, 'text');
766
- label.setAttribute('class', 'axis-label');
767
- label.setAttribute('x', points[i].x);
768
- label.setAttribute('y', h - 6);
769
- label.setAttribute('text-anchor', idx === 0 ? 'start' : (idx === arr.length - 1 ? 'end' : 'middle'));
770
- label.textContent = formatDateLabel(points[i].d.date);
771
- svg.appendChild(label);
772
- });
773
-
774
- var path = document.createElementNS(SVG_NS, 'path');
775
- var dAttr = points.map(function (p, i) { return (i === 0 ? 'M' : 'L') + p.x.toFixed(1) + ',' + p.y.toFixed(1); }).join(' ');
776
- path.setAttribute('d', dAttr);
777
- path.setAttribute('fill', 'none');
778
- path.setAttribute('stroke', 'var(--series-1)');
779
- path.setAttribute('stroke-width', '2');
780
- path.setAttribute('stroke-linejoin', 'round');
781
- path.setAttribute('stroke-linecap', 'round');
782
- svg.appendChild(path);
783
-
784
- // end-dot + direct label on the last point
785
- var lastP = points[points.length - 1];
786
- var endDot = document.createElementNS(SVG_NS, 'circle');
787
- endDot.setAttribute('cx', lastP.x); endDot.setAttribute('cy', lastP.y);
788
- endDot.setAttribute('r', 4);
789
- endDot.setAttribute('fill', 'var(--series-1)');
790
- endDot.setAttribute('stroke', 'var(--surface-1)');
791
- endDot.setAttribute('stroke-width', '2');
792
- svg.appendChild(endDot);
793
- var endLabel = document.createElementNS(SVG_NS, 'text');
794
- endLabel.setAttribute('class', 'direct-label');
795
- endLabel.setAttribute('x', Math.min(lastP.x + 8, w - padR - 40));
796
- endLabel.setAttribute('y', lastP.y - 8);
797
- endLabel.textContent = formatUsd(lastP.d.cost_usd);
798
- svg.appendChild(endLabel);
799
-
800
- // crosshair
801
- var crosshair = document.createElementNS(SVG_NS, 'line');
802
- crosshair.setAttribute('class', 'axis-line');
803
- crosshair.setAttribute('y1', padT); crosshair.setAttribute('y2', padT + plotH);
804
- crosshair.setAttribute('stroke-dasharray', 'none');
805
- crosshair.style.opacity = '0';
806
- svg.appendChild(crosshair);
807
-
808
- var hitLayer = document.createElementNS(SVG_NS, 'rect');
809
- hitLayer.setAttribute('x', padL); hitLayer.setAttribute('y', padT);
810
- hitLayer.setAttribute('width', plotW); hitLayer.setAttribute('height', plotH);
811
- hitLayer.setAttribute('fill', 'transparent');
812
- svg.appendChild(hitLayer);
813
-
814
- container.appendChild(svg);
815
- var tip = ensureTooltip(container);
816
-
817
- function handleMove(clientX, clientY) {
818
- var svgRect = svg.getBoundingClientRect();
819
- var scaleX = w / svgRect.width;
820
- var localX = (clientX - svgRect.left) * scaleX;
821
- var idx = Math.round((localX - padL) / (stepX || 1));
822
- idx = Math.max(0, Math.min(points.length - 1, idx));
823
- var p = points[idx];
824
- crosshair.setAttribute('x1', p.x); crosshair.setAttribute('x2', p.x);
825
- crosshair.style.opacity = '1';
826
- setTooltipRows(tip, formatDateLabel(p.d.date), [
827
- { color: 'var(--series-1)', label: 'Cost', value: formatUsd(p.d.cost_usd) }
828
- ]);
829
- tip.classList.add('visible');
830
- var localY = (clientY - svgRect.top) * (h / svgRect.height);
831
- positionTooltip(tip, container, (p.x / w) * svgRect.width, (p.y / h) * svgRect.height);
832
- }
833
- hitLayer.addEventListener('pointermove', function (e) { handleMove(e.clientX, e.clientY); });
834
- hitLayer.addEventListener('pointerleave', function () {
835
- crosshair.style.opacity = '0';
836
- tip.classList.remove('visible');
837
- });
838
- }
839
-
840
- // ---- Requests by outcome: stacked bar chart ----
841
-
842
- function renderOutcomeChart(data) {
843
- var container = document.getElementById('outcomeChart');
844
- var legend = document.getElementById('outcomeLegend');
845
- container.innerHTML = '';
846
-
847
- var series = [
848
- { key: 'exact_hits', label: 'Exact hit', color: 'var(--series-1)' },
849
- { key: 'semantic_hits', label: 'Semantic hit', color: 'var(--series-2)' },
850
- { key: 'misses', label: 'Miss (dispatched)', color: 'var(--series-3)' }
851
- ];
852
-
853
- legend.innerHTML = '';
854
- series.forEach(function (s) {
855
- var item = document.createElement('span');
856
- item.className = 'item';
857
- var sw = document.createElement('span');
858
- sw.className = 'swatch';
859
- sw.style.background = s.color;
860
- var label = document.createElement('span');
861
- label.textContent = s.label;
862
- item.appendChild(sw); item.appendChild(label);
863
- legend.appendChild(item);
864
- });
865
-
866
- if (!data.daily.length) {
867
- container.innerHTML = '<div class="empty-note">No requests recorded in this range yet.</div>';
868
- return;
869
- }
870
-
871
- if (viewMode.outcomeChart === 'table') {
872
- container.appendChild(buildTable(
873
- ['Date', 'Exact hit', 'Semantic hit', 'Miss', 'Errors', 'Total'],
874
- data.daily.map(function (d) {
875
- return [formatDateLabel(d.date), formatCount(d.exact_hits), formatCount(d.semantic_hits), formatCount(d.misses), formatCount(d.errors), formatCount(d.requests)];
876
- }),
877
- [false, true, true, true, true, true]
878
- ));
879
- return;
880
- }
881
-
882
- var w = 760, h = 220, padL = 40, padR = 16, padT = 16, padB = 28;
883
- var plotW = w - padL - padR, plotH = h - padT - padB;
884
- var rawMaxTotal = Math.max.apply(null, data.daily.map(function (d) { return d.requests; }).concat([1]));
885
- var axis = niceAxisTicks(rawMaxTotal);
886
- var maxTotal = axis.max;
887
-
888
- var svg = document.createElementNS(SVG_NS, 'svg');
889
- svg.setAttribute('class', 'chart');
890
- svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
891
-
892
- axis.ticks.forEach(function (tickValue) {
893
- var gy = padT + plotH - (tickValue / maxTotal) * plotH;
894
- var line = document.createElementNS(SVG_NS, 'line');
895
- line.setAttribute('class', 'gridline');
896
- line.setAttribute('x1', padL); line.setAttribute('x2', w - padR);
897
- line.setAttribute('y1', gy); line.setAttribute('y2', gy);
898
- svg.appendChild(line);
899
- var label = document.createElementNS(SVG_NS, 'text');
900
- label.setAttribute('class', 'axis-label');
901
- label.setAttribute('x', padL - 6);
902
- label.setAttribute('y', gy + 4);
903
- label.setAttribute('text-anchor', 'end');
904
- label.textContent = formatCount(Math.round(tickValue));
905
- svg.appendChild(label);
906
- });
907
-
908
- var n = data.daily.length;
909
- var slotW = plotW / n;
910
- var barW = Math.min(24, slotW * 0.6);
911
- var gap = 2;
912
-
913
- container.appendChild(svg);
914
- var tip = ensureTooltip(container);
915
-
916
- data.daily.forEach(function (d, i) {
917
- var slotX = padL + i * slotW + (slotW - barW) / 2;
918
- var yCursor = padT + plotH;
919
- var segs = [
920
- { value: d.exact_hits, color: 'var(--series-1)' },
921
- { value: d.semantic_hits, color: 'var(--series-2)' },
922
- { value: d.misses, color: 'var(--series-3)' }
923
- ];
924
- var group = document.createElementNS(SVG_NS, 'g');
925
- segs.forEach(function (seg) {
926
- if (seg.value <= 0) return;
927
- var segH = (seg.value / maxTotal) * plotH;
928
- var rect = document.createElementNS(SVG_NS, 'rect');
929
- rect.setAttribute('x', slotX);
930
- rect.setAttribute('y', yCursor - segH);
931
- rect.setAttribute('width', barW);
932
- rect.setAttribute('height', Math.max(segH - gap, 0));
933
- rect.setAttribute('rx', 2);
934
- rect.setAttribute('fill', seg.color);
935
- group.appendChild(rect);
936
- yCursor -= segH;
937
- });
938
-
939
- var hitRect = document.createElementNS(SVG_NS, 'rect');
940
- hitRect.setAttribute('x', padL + i * slotW);
941
- hitRect.setAttribute('y', padT);
942
- hitRect.setAttribute('width', slotW);
943
- hitRect.setAttribute('height', plotH);
944
- hitRect.setAttribute('fill', 'transparent');
945
- hitRect.addEventListener('pointermove', function (e) {
946
- var svgRect = svg.getBoundingClientRect();
947
- var rows = [
948
- { color: 'var(--series-1)', label: 'Exact hit', value: formatCount(d.exact_hits) },
949
- { color: 'var(--series-2)', label: 'Semantic hit', value: formatCount(d.semantic_hits) },
950
- { color: 'var(--series-3)', label: 'Miss', value: formatCount(d.misses) }
951
- ];
952
- var note = d.errors > 0 ? (d.errors + ' of the misses errored') : null;
953
- setTooltipRows(tip, formatDateLabel(d.date) + ' · ' + formatCount(d.requests) + ' requests', rows, note);
954
- tip.classList.add('visible');
955
- positionTooltip(tip, container, (e.clientX - svgRect.left), (e.clientY - svgRect.top));
956
- });
957
- hitRect.addEventListener('pointerleave', function () { tip.classList.remove('visible'); });
958
- group.appendChild(hitRect);
959
-
960
- if (i === 0 || i === n - 1 || i === Math.floor((n - 1) / 2)) {
961
- var label = document.createElementNS(SVG_NS, 'text');
962
- label.setAttribute('class', 'axis-label');
963
- label.setAttribute('x', padL + i * slotW + slotW / 2);
964
- label.setAttribute('y', h - 6);
965
- label.setAttribute('text-anchor', 'middle');
966
- label.textContent = formatDateLabel(d.date);
967
- svg.appendChild(label);
968
- }
969
-
970
- svg.appendChild(group);
971
- });
972
- }
973
-
974
- // ---- Cost by provider: simple bar chart ----
975
-
976
- function renderProviderChart(data) {
977
- var container = document.getElementById('providerChart');
978
- container.innerHTML = '';
979
-
980
- var names = Object.keys(data.by_provider).sort();
981
- if (!names.length) {
982
- container.innerHTML = '<div class="empty-note">No provider activity in this range yet.</div>';
983
- return;
984
- }
985
-
986
- if (viewMode.providerChart === 'table') {
987
- container.appendChild(buildTable(
988
- ['Provider', 'Cost'],
989
- names.map(function (name) { return [name, formatUsd(data.by_provider[name].cost_usd)]; }),
990
- [false, true]
991
- ));
992
- return;
993
- }
994
-
995
- var colors = ['var(--series-1)', 'var(--series-2)', 'var(--series-3)'];
996
- var w = 760, h = 160, padL = 90, padR = 60, padT = 10, padB = 10;
997
- var plotW = w - padL - padR, plotH = h - padT - padB;
998
- var maxCost = Math.max.apply(null, names.map(function (n) { return data.by_provider[n].cost_usd; }).concat([0.0001]));
999
- var barH = 22, gap = (plotH - names.length * barH) / Math.max(names.length - 1, 1);
1000
-
1001
- var svg = document.createElementNS(SVG_NS, 'svg');
1002
- svg.setAttribute('class', 'chart');
1003
- svg.setAttribute('viewBox', '0 0 ' + w + ' ' + Math.max(h, names.length * (barH + 10)));
1004
-
1005
- container.appendChild(svg);
1006
- var tip = ensureTooltip(container);
1007
-
1008
- names.forEach(function (name, i) {
1009
- var cost = data.by_provider[name].cost_usd;
1010
- var y = padT + i * (barH + 10);
1011
- var barLen = (cost / maxCost) * plotW;
1012
- var color = colors[i % colors.length];
1013
-
1014
- var label = document.createElementNS(SVG_NS, 'text');
1015
- label.setAttribute('class', 'axis-label');
1016
- label.setAttribute('x', padL - 8);
1017
- label.setAttribute('y', y + barH / 2 + 4);
1018
- label.setAttribute('text-anchor', 'end');
1019
- label.textContent = name;
1020
- svg.appendChild(label);
1021
-
1022
- var rect = document.createElementNS(SVG_NS, 'rect');
1023
- rect.setAttribute('x', padL);
1024
- rect.setAttribute('y', y);
1025
- rect.setAttribute('width', Math.max(barLen, 2));
1026
- rect.setAttribute('height', barH);
1027
- rect.setAttribute('rx', 4);
1028
- rect.setAttribute('fill', color);
1029
- svg.appendChild(rect);
1030
-
1031
- var valueLabel = document.createElementNS(SVG_NS, 'text');
1032
- valueLabel.setAttribute('class', 'direct-label');
1033
- valueLabel.setAttribute('x', padL + barLen + 8);
1034
- valueLabel.setAttribute('y', y + barH / 2 + 4);
1035
- valueLabel.textContent = formatUsd(cost);
1036
- svg.appendChild(valueLabel);
1037
-
1038
- var hitRect = document.createElementNS(SVG_NS, 'rect');
1039
- hitRect.setAttribute('x', padL);
1040
- hitRect.setAttribute('y', y);
1041
- hitRect.setAttribute('width', plotW);
1042
- hitRect.setAttribute('height', barH);
1043
- hitRect.setAttribute('fill', 'transparent');
1044
- hitRect.addEventListener('pointermove', function (e) {
1045
- var svgRect = svg.getBoundingClientRect();
1046
- setTooltipRows(tip, name, [
1047
- { color: color, label: 'Cost', value: formatUsd(cost) },
1048
- { color: color, label: 'Requests', value: formatCount(data.by_provider[name].requests) }
1049
- ]);
1050
- tip.classList.add('visible');
1051
- positionTooltip(tip, container, (e.clientX - svgRect.left), (e.clientY - svgRect.top));
1052
- });
1053
- hitRect.addEventListener('pointerleave', function () { tip.classList.remove('visible'); });
1054
- svg.appendChild(hitRect);
1055
- });
1056
- }
1057
-
1058
- // ---- Provider health table ----
1059
-
1060
- function renderProviderTable(data) {
1061
- var container = document.getElementById('providerTable');
1062
- container.innerHTML = '';
1063
- var names = Object.keys(data.by_provider).sort();
1064
- if (!names.length) {
1065
- container.innerHTML = '<div class="empty-note">No provider activity in this range yet.</div>';
1066
- return;
1067
- }
1068
- container.appendChild(buildTable(
1069
- ['Provider', 'Requests', 'Cost', 'Error rate', 'Avg latency'],
1070
- names.map(function (name) {
1071
- var p = data.by_provider[name];
1072
- return [name, formatCount(p.requests), formatUsd(p.cost_usd), formatPercent(p.errorRate), formatMs(p.avgLatencyMs)];
1073
- }),
1074
- [false, true, true, true, true]
1075
- ));
1076
- }
1077
-
1078
- // ---- shared table builder (the accessible twin of every chart) ----
1079
-
1080
- function buildTable(headers, rows, numericCols) {
1081
- var table = document.createElement('table');
1082
- table.className = 'data-table';
1083
- var thead = document.createElement('thead');
1084
- var headRow = document.createElement('tr');
1085
- headers.forEach(function (hLabel, i) {
1086
- var th = document.createElement('th');
1087
- if (numericCols && numericCols[i]) th.className = 'num';
1088
- th.textContent = hLabel;
1089
- headRow.appendChild(th);
1090
- });
1091
- thead.appendChild(headRow);
1092
- table.appendChild(thead);
1093
- var tbody = document.createElement('tbody');
1094
- rows.forEach(function (row) {
1095
- var tr = document.createElement('tr');
1096
- row.forEach(function (cell, i) {
1097
- var td = document.createElement('td');
1098
- if (numericCols && numericCols[i]) td.className = 'num';
1099
- td.textContent = cell;
1100
- tr.appendChild(td);
1101
- });
1102
- tbody.appendChild(tr);
1103
- });
1104
- table.appendChild(tbody);
1105
- return table;
1106
- }
1107
-
1108
- // ---------- boot ----------
1109
-
1110
- if (getStoredKey()) {
1111
- loadDashboard(currentDays);
1112
- } else {
1113
- showKeyGate('');
1114
- }
1115
- })();
1116
- </script>
1117
-
1118
- </body>
1119
- </html>
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: 'Total requests', value: formatCount(data.sample_size) },
598
+ { label: 'Exact hit rate', value: formatPercent(data.cache_hit_rate.exact) },
599
+ { label: 'Semantic hit rate', value: formatPercent(data.cache_hit_rate.semantic) },
600
+ { label: 'Combined hit rate', value: formatPercent(data.cache_hit_rate.combined) },
601
+ { label: 'Error rate', value: formatPercent(data.error_rate), critical: data.error_rate > 0.05 }
602
+ ];
603
+ var row = document.getElementById('kpiRow');
604
+ row.innerHTML = '';
605
+ tiles.forEach(function (t) {
606
+ var tile = document.createElement('div');
607
+ tile.className = 'stat-tile';
608
+ var label = document.createElement('div');
609
+ label.className = 'label';
610
+ label.textContent = t.label;
611
+ var value = document.createElement('div');
612
+ value.className = 'value' + (t.critical ? ' critical' : '');
613
+ value.textContent = t.value;
614
+ tile.appendChild(label);
615
+ tile.appendChild(value);
616
+ if (t.spark && t.spark.length > 1) {
617
+ tile.appendChild(sparkline(t.spark));
618
+ }
619
+ row.appendChild(tile);
620
+ });
621
+ }
622
+
623
+ function sparkline(values) {
624
+ var w = 140, h = 28;
625
+ var max = Math.max.apply(null, values.concat([0.0001]));
626
+ var min = Math.min.apply(null, values.concat([0]));
627
+ var range = (max - min) || 1;
628
+ var svg = document.createElementNS(SVG_NS, 'svg');
629
+ svg.setAttribute('class', 'spark');
630
+ svg.setAttribute('width', w);
631
+ svg.setAttribute('height', h);
632
+ svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
633
+ var step = values.length > 1 ? w / (values.length - 1) : 0;
634
+ var points = values.map(function (v, i) {
635
+ var x = i * step;
636
+ var y = h - 4 - ((v - min) / range) * (h - 8);
637
+ return [x, y];
638
+ });
639
+ var path = document.createElementNS(SVG_NS, 'path');
640
+ var d = points.map(function (p, i) { return (i === 0 ? 'M' : 'L') + p[0].toFixed(1) + ',' + p[1].toFixed(1); }).join(' ');
641
+ path.setAttribute('d', d);
642
+ path.setAttribute('fill', 'none');
643
+ path.setAttribute('stroke', 'var(--series-1)');
644
+ path.setAttribute('stroke-width', '2');
645
+ path.setAttribute('stroke-linejoin', 'round');
646
+ path.setAttribute('stroke-linecap', 'round');
647
+ svg.appendChild(path);
648
+ var last = points[points.length - 1];
649
+ var dot = document.createElementNS(SVG_NS, 'circle');
650
+ dot.setAttribute('cx', last[0]);
651
+ dot.setAttribute('cy', last[1]);
652
+ dot.setAttribute('r', 3);
653
+ dot.setAttribute('fill', 'var(--series-1)');
654
+ svg.appendChild(dot);
655
+ return svg;
656
+ }
657
+
658
+ function ensureTooltip(container) {
659
+ var tip = container.querySelector('.tooltip');
660
+ if (!tip) {
661
+ tip = document.createElement('div');
662
+ tip.className = 'tooltip';
663
+ container.appendChild(tip);
664
+ }
665
+ return tip;
666
+ }
667
+
668
+ function positionTooltip(tip, container, x, y) {
669
+ var rect = container.getBoundingClientRect();
670
+ var tipWidth = tip.offsetWidth || 140;
671
+ var left = Math.min(Math.max(x + 12, 4), rect.width - tipWidth - 4);
672
+ tip.style.left = left + 'px';
673
+ tip.style.top = Math.max(y - 12, 4) + 'px';
674
+ }
675
+
676
+ function setTooltipRows(tip, title, rows, note) {
677
+ tip.textContent = '';
678
+ var titleEl = document.createElement('div');
679
+ titleEl.className = 't-title';
680
+ titleEl.textContent = title;
681
+ tip.appendChild(titleEl);
682
+ rows.forEach(function (r) {
683
+ var rowEl = document.createElement('div');
684
+ rowEl.className = 't-row';
685
+ var key = document.createElement('span');
686
+ key.className = 't-key';
687
+ key.style.background = r.color;
688
+ var label = document.createElement('span');
689
+ label.textContent = r.label + ':';
690
+ var value = document.createElement('span');
691
+ value.className = 't-value';
692
+ value.textContent = r.value;
693
+ rowEl.appendChild(key);
694
+ rowEl.appendChild(label);
695
+ rowEl.appendChild(value);
696
+ tip.appendChild(rowEl);
697
+ });
698
+ if (note) {
699
+ var noteEl = document.createElement('div');
700
+ noteEl.className = 't-note';
701
+ noteEl.textContent = note;
702
+ tip.appendChild(noteEl);
703
+ }
704
+ }
705
+
706
+ // ---- Cost over time: single-series line chart ----
707
+
708
+ function renderCostChart(data) {
709
+ var container = document.getElementById('costChart');
710
+ container.innerHTML = '';
711
+
712
+ if (!data.daily.length) {
713
+ container.innerHTML = '<div class="empty-note">No requests recorded in this range yet.</div>';
714
+ return;
715
+ }
716
+
717
+ if (viewMode.costChart === 'table') {
718
+ container.appendChild(buildTable(
719
+ ['Date', 'Cost'],
720
+ data.daily.map(function (d) { return [formatDateLabel(d.date), formatUsd(d.cost_usd)]; }),
721
+ [false, true]
722
+ ));
723
+ return;
724
+ }
725
+
726
+ var w = 760, h = 220, padL = 46, padR = 16, padT = 16, padB = 28;
727
+ var plotW = w - padL - padR, plotH = h - padT - padB;
728
+ var values = data.daily.map(function (d) { return d.cost_usd; });
729
+ var rawMax = Math.max.apply(null, values.concat([0.0001]));
730
+ var axis = niceAxisTicks(rawMax);
731
+ var maxV = axis.max;
732
+
733
+ var svg = document.createElementNS(SVG_NS, 'svg');
734
+ svg.setAttribute('class', 'chart');
735
+ svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
736
+
737
+ // gridlines + y-axis labels, at clean ("nice") values, not at
738
+ // whatever fraction of the raw max a fixed step count produces.
739
+ axis.ticks.forEach(function (tickValue) {
740
+ var gy = padT + plotH - (tickValue / maxV) * plotH;
741
+ var line = document.createElementNS(SVG_NS, 'line');
742
+ line.setAttribute('class', 'gridline');
743
+ line.setAttribute('x1', padL); line.setAttribute('x2', w - padR);
744
+ line.setAttribute('y1', gy); line.setAttribute('y2', gy);
745
+ svg.appendChild(line);
746
+ var label = document.createElementNS(SVG_NS, 'text');
747
+ label.setAttribute('class', 'axis-label');
748
+ label.setAttribute('x', padL - 8);
749
+ label.setAttribute('y', gy + 4);
750
+ label.setAttribute('text-anchor', 'end');
751
+ label.textContent = formatUsd(tickValue);
752
+ svg.appendChild(label);
753
+ });
754
+
755
+ var stepX = data.daily.length > 1 ? plotW / (data.daily.length - 1) : 0;
756
+ var points = data.daily.map(function (d, i) {
757
+ var x = padL + i * stepX;
758
+ var y = padT + plotH - (d.cost_usd / maxV) * plotH;
759
+ return { x: x, y: y, d: d };
760
+ });
761
+
762
+ // x-axis date labels: first, middle, last only (avoid crowding)
763
+ [0, Math.floor((points.length - 1) / 2), points.length - 1].forEach(function (i, idx, arr) {
764
+ if (idx > 0 && arr[idx] === arr[idx - 1]) return;
765
+ var label = document.createElementNS(SVG_NS, 'text');
766
+ label.setAttribute('class', 'axis-label');
767
+ label.setAttribute('x', points[i].x);
768
+ label.setAttribute('y', h - 6);
769
+ label.setAttribute('text-anchor', idx === 0 ? 'start' : (idx === arr.length - 1 ? 'end' : 'middle'));
770
+ label.textContent = formatDateLabel(points[i].d.date);
771
+ svg.appendChild(label);
772
+ });
773
+
774
+ var path = document.createElementNS(SVG_NS, 'path');
775
+ var dAttr = points.map(function (p, i) { return (i === 0 ? 'M' : 'L') + p.x.toFixed(1) + ',' + p.y.toFixed(1); }).join(' ');
776
+ path.setAttribute('d', dAttr);
777
+ path.setAttribute('fill', 'none');
778
+ path.setAttribute('stroke', 'var(--series-1)');
779
+ path.setAttribute('stroke-width', '2');
780
+ path.setAttribute('stroke-linejoin', 'round');
781
+ path.setAttribute('stroke-linecap', 'round');
782
+ svg.appendChild(path);
783
+
784
+ // end-dot + direct label on the last point
785
+ var lastP = points[points.length - 1];
786
+ var endDot = document.createElementNS(SVG_NS, 'circle');
787
+ endDot.setAttribute('cx', lastP.x); endDot.setAttribute('cy', lastP.y);
788
+ endDot.setAttribute('r', 4);
789
+ endDot.setAttribute('fill', 'var(--series-1)');
790
+ endDot.setAttribute('stroke', 'var(--surface-1)');
791
+ endDot.setAttribute('stroke-width', '2');
792
+ svg.appendChild(endDot);
793
+ var endLabel = document.createElementNS(SVG_NS, 'text');
794
+ endLabel.setAttribute('class', 'direct-label');
795
+ endLabel.setAttribute('x', Math.min(lastP.x + 8, w - padR - 40));
796
+ endLabel.setAttribute('y', lastP.y - 8);
797
+ endLabel.textContent = formatUsd(lastP.d.cost_usd);
798
+ svg.appendChild(endLabel);
799
+
800
+ // crosshair
801
+ var crosshair = document.createElementNS(SVG_NS, 'line');
802
+ crosshair.setAttribute('class', 'axis-line');
803
+ crosshair.setAttribute('y1', padT); crosshair.setAttribute('y2', padT + plotH);
804
+ crosshair.setAttribute('stroke-dasharray', 'none');
805
+ crosshair.style.opacity = '0';
806
+ svg.appendChild(crosshair);
807
+
808
+ var hitLayer = document.createElementNS(SVG_NS, 'rect');
809
+ hitLayer.setAttribute('x', padL); hitLayer.setAttribute('y', padT);
810
+ hitLayer.setAttribute('width', plotW); hitLayer.setAttribute('height', plotH);
811
+ hitLayer.setAttribute('fill', 'transparent');
812
+ svg.appendChild(hitLayer);
813
+
814
+ container.appendChild(svg);
815
+ var tip = ensureTooltip(container);
816
+
817
+ function handleMove(clientX, clientY) {
818
+ var svgRect = svg.getBoundingClientRect();
819
+ var scaleX = w / svgRect.width;
820
+ var localX = (clientX - svgRect.left) * scaleX;
821
+ var idx = Math.round((localX - padL) / (stepX || 1));
822
+ idx = Math.max(0, Math.min(points.length - 1, idx));
823
+ var p = points[idx];
824
+ crosshair.setAttribute('x1', p.x); crosshair.setAttribute('x2', p.x);
825
+ crosshair.style.opacity = '1';
826
+ setTooltipRows(tip, formatDateLabel(p.d.date), [
827
+ { color: 'var(--series-1)', label: 'Cost', value: formatUsd(p.d.cost_usd) }
828
+ ]);
829
+ tip.classList.add('visible');
830
+ var localY = (clientY - svgRect.top) * (h / svgRect.height);
831
+ positionTooltip(tip, container, (p.x / w) * svgRect.width, (p.y / h) * svgRect.height);
832
+ }
833
+ hitLayer.addEventListener('pointermove', function (e) { handleMove(e.clientX, e.clientY); });
834
+ hitLayer.addEventListener('pointerleave', function () {
835
+ crosshair.style.opacity = '0';
836
+ tip.classList.remove('visible');
837
+ });
838
+ }
839
+
840
+ // ---- Requests by outcome: stacked bar chart ----
841
+
842
+ function renderOutcomeChart(data) {
843
+ var container = document.getElementById('outcomeChart');
844
+ var legend = document.getElementById('outcomeLegend');
845
+ container.innerHTML = '';
846
+
847
+ var series = [
848
+ { key: 'exact_hits', label: 'Exact hit', color: 'var(--series-1)' },
849
+ { key: 'semantic_hits', label: 'Semantic hit', color: 'var(--series-2)' },
850
+ { key: 'misses', label: 'Miss (dispatched)', color: 'var(--series-3)' }
851
+ ];
852
+
853
+ legend.innerHTML = '';
854
+ series.forEach(function (s) {
855
+ var item = document.createElement('span');
856
+ item.className = 'item';
857
+ var sw = document.createElement('span');
858
+ sw.className = 'swatch';
859
+ sw.style.background = s.color;
860
+ var label = document.createElement('span');
861
+ label.textContent = s.label;
862
+ item.appendChild(sw); item.appendChild(label);
863
+ legend.appendChild(item);
864
+ });
865
+
866
+ if (!data.daily.length) {
867
+ container.innerHTML = '<div class="empty-note">No requests recorded in this range yet.</div>';
868
+ return;
869
+ }
870
+
871
+ if (viewMode.outcomeChart === 'table') {
872
+ container.appendChild(buildTable(
873
+ ['Date', 'Exact hit', 'Semantic hit', 'Miss', 'Errors', 'Total'],
874
+ data.daily.map(function (d) {
875
+ return [formatDateLabel(d.date), formatCount(d.exact_hits), formatCount(d.semantic_hits), formatCount(d.misses), formatCount(d.errors), formatCount(d.requests)];
876
+ }),
877
+ [false, true, true, true, true, true]
878
+ ));
879
+ return;
880
+ }
881
+
882
+ var w = 760, h = 220, padL = 40, padR = 16, padT = 16, padB = 28;
883
+ var plotW = w - padL - padR, plotH = h - padT - padB;
884
+ var rawMaxTotal = Math.max.apply(null, data.daily.map(function (d) { return d.requests; }).concat([1]));
885
+ var axis = niceAxisTicks(rawMaxTotal);
886
+ var maxTotal = axis.max;
887
+
888
+ var svg = document.createElementNS(SVG_NS, 'svg');
889
+ svg.setAttribute('class', 'chart');
890
+ svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
891
+
892
+ axis.ticks.forEach(function (tickValue) {
893
+ var gy = padT + plotH - (tickValue / maxTotal) * plotH;
894
+ var line = document.createElementNS(SVG_NS, 'line');
895
+ line.setAttribute('class', 'gridline');
896
+ line.setAttribute('x1', padL); line.setAttribute('x2', w - padR);
897
+ line.setAttribute('y1', gy); line.setAttribute('y2', gy);
898
+ svg.appendChild(line);
899
+ var label = document.createElementNS(SVG_NS, 'text');
900
+ label.setAttribute('class', 'axis-label');
901
+ label.setAttribute('x', padL - 6);
902
+ label.setAttribute('y', gy + 4);
903
+ label.setAttribute('text-anchor', 'end');
904
+ label.textContent = formatCount(Math.round(tickValue));
905
+ svg.appendChild(label);
906
+ });
907
+
908
+ var n = data.daily.length;
909
+ var slotW = plotW / n;
910
+ var barW = Math.min(24, slotW * 0.6);
911
+ var gap = 2;
912
+
913
+ container.appendChild(svg);
914
+ var tip = ensureTooltip(container);
915
+
916
+ data.daily.forEach(function (d, i) {
917
+ var slotX = padL + i * slotW + (slotW - barW) / 2;
918
+ var yCursor = padT + plotH;
919
+ var segs = [
920
+ { value: d.exact_hits, color: 'var(--series-1)' },
921
+ { value: d.semantic_hits, color: 'var(--series-2)' },
922
+ { value: d.misses, color: 'var(--series-3)' }
923
+ ];
924
+ var group = document.createElementNS(SVG_NS, 'g');
925
+ segs.forEach(function (seg) {
926
+ if (seg.value <= 0) return;
927
+ var segH = (seg.value / maxTotal) * plotH;
928
+ var rect = document.createElementNS(SVG_NS, 'rect');
929
+ rect.setAttribute('x', slotX);
930
+ rect.setAttribute('y', yCursor - segH);
931
+ rect.setAttribute('width', barW);
932
+ rect.setAttribute('height', Math.max(segH - gap, 0));
933
+ rect.setAttribute('rx', 2);
934
+ rect.setAttribute('fill', seg.color);
935
+ group.appendChild(rect);
936
+ yCursor -= segH;
937
+ });
938
+
939
+ var hitRect = document.createElementNS(SVG_NS, 'rect');
940
+ hitRect.setAttribute('x', padL + i * slotW);
941
+ hitRect.setAttribute('y', padT);
942
+ hitRect.setAttribute('width', slotW);
943
+ hitRect.setAttribute('height', plotH);
944
+ hitRect.setAttribute('fill', 'transparent');
945
+ hitRect.addEventListener('pointermove', function (e) {
946
+ var svgRect = svg.getBoundingClientRect();
947
+ var rows = [
948
+ { color: 'var(--series-1)', label: 'Exact hit', value: formatCount(d.exact_hits) },
949
+ { color: 'var(--series-2)', label: 'Semantic hit', value: formatCount(d.semantic_hits) },
950
+ { color: 'var(--series-3)', label: 'Miss', value: formatCount(d.misses) }
951
+ ];
952
+ var note = d.errors > 0 ? (d.errors + ' of the misses errored') : null;
953
+ setTooltipRows(tip, formatDateLabel(d.date) + ' · ' + formatCount(d.requests) + ' requests', rows, note);
954
+ tip.classList.add('visible');
955
+ positionTooltip(tip, container, (e.clientX - svgRect.left), (e.clientY - svgRect.top));
956
+ });
957
+ hitRect.addEventListener('pointerleave', function () { tip.classList.remove('visible'); });
958
+ group.appendChild(hitRect);
959
+
960
+ if (i === 0 || i === n - 1 || i === Math.floor((n - 1) / 2)) {
961
+ var label = document.createElementNS(SVG_NS, 'text');
962
+ label.setAttribute('class', 'axis-label');
963
+ label.setAttribute('x', padL + i * slotW + slotW / 2);
964
+ label.setAttribute('y', h - 6);
965
+ label.setAttribute('text-anchor', 'middle');
966
+ label.textContent = formatDateLabel(d.date);
967
+ svg.appendChild(label);
968
+ }
969
+
970
+ svg.appendChild(group);
971
+ });
972
+ }
973
+
974
+ // ---- Cost by provider: simple bar chart ----
975
+
976
+ function renderProviderChart(data) {
977
+ var container = document.getElementById('providerChart');
978
+ container.innerHTML = '';
979
+
980
+ var names = Object.keys(data.by_provider).sort();
981
+ if (!names.length) {
982
+ container.innerHTML = '<div class="empty-note">No provider activity in this range yet.</div>';
983
+ return;
984
+ }
985
+
986
+ if (viewMode.providerChart === 'table') {
987
+ container.appendChild(buildTable(
988
+ ['Provider', 'Cost'],
989
+ names.map(function (name) { return [name, formatUsd(data.by_provider[name].cost_usd)]; }),
990
+ [false, true]
991
+ ));
992
+ return;
993
+ }
994
+
995
+ var colors = ['var(--series-1)', 'var(--series-2)', 'var(--series-3)'];
996
+ var w = 760, h = 160, padL = 90, padR = 60, padT = 10, padB = 10;
997
+ var plotW = w - padL - padR, plotH = h - padT - padB;
998
+ var maxCost = Math.max.apply(null, names.map(function (n) { return data.by_provider[n].cost_usd; }).concat([0.0001]));
999
+ var barH = 22, gap = (plotH - names.length * barH) / Math.max(names.length - 1, 1);
1000
+
1001
+ var svg = document.createElementNS(SVG_NS, 'svg');
1002
+ svg.setAttribute('class', 'chart');
1003
+ svg.setAttribute('viewBox', '0 0 ' + w + ' ' + Math.max(h, names.length * (barH + 10)));
1004
+
1005
+ container.appendChild(svg);
1006
+ var tip = ensureTooltip(container);
1007
+
1008
+ names.forEach(function (name, i) {
1009
+ var cost = data.by_provider[name].cost_usd;
1010
+ var y = padT + i * (barH + 10);
1011
+ var barLen = (cost / maxCost) * plotW;
1012
+ var color = colors[i % colors.length];
1013
+
1014
+ var label = document.createElementNS(SVG_NS, 'text');
1015
+ label.setAttribute('class', 'axis-label');
1016
+ label.setAttribute('x', padL - 8);
1017
+ label.setAttribute('y', y + barH / 2 + 4);
1018
+ label.setAttribute('text-anchor', 'end');
1019
+ label.textContent = name;
1020
+ svg.appendChild(label);
1021
+
1022
+ var rect = document.createElementNS(SVG_NS, 'rect');
1023
+ rect.setAttribute('x', padL);
1024
+ rect.setAttribute('y', y);
1025
+ rect.setAttribute('width', Math.max(barLen, 2));
1026
+ rect.setAttribute('height', barH);
1027
+ rect.setAttribute('rx', 4);
1028
+ rect.setAttribute('fill', color);
1029
+ svg.appendChild(rect);
1030
+
1031
+ var valueLabel = document.createElementNS(SVG_NS, 'text');
1032
+ valueLabel.setAttribute('class', 'direct-label');
1033
+ valueLabel.setAttribute('x', padL + barLen + 8);
1034
+ valueLabel.setAttribute('y', y + barH / 2 + 4);
1035
+ valueLabel.textContent = formatUsd(cost);
1036
+ svg.appendChild(valueLabel);
1037
+
1038
+ var hitRect = document.createElementNS(SVG_NS, 'rect');
1039
+ hitRect.setAttribute('x', padL);
1040
+ hitRect.setAttribute('y', y);
1041
+ hitRect.setAttribute('width', plotW);
1042
+ hitRect.setAttribute('height', barH);
1043
+ hitRect.setAttribute('fill', 'transparent');
1044
+ hitRect.addEventListener('pointermove', function (e) {
1045
+ var svgRect = svg.getBoundingClientRect();
1046
+ setTooltipRows(tip, name, [
1047
+ { color: color, label: 'Cost', value: formatUsd(cost) },
1048
+ { color: color, label: 'Requests', value: formatCount(data.by_provider[name].requests) }
1049
+ ]);
1050
+ tip.classList.add('visible');
1051
+ positionTooltip(tip, container, (e.clientX - svgRect.left), (e.clientY - svgRect.top));
1052
+ });
1053
+ hitRect.addEventListener('pointerleave', function () { tip.classList.remove('visible'); });
1054
+ svg.appendChild(hitRect);
1055
+ });
1056
+ }
1057
+
1058
+ // ---- Provider health table ----
1059
+
1060
+ function renderProviderTable(data) {
1061
+ var container = document.getElementById('providerTable');
1062
+ container.innerHTML = '';
1063
+ var names = Object.keys(data.by_provider).sort();
1064
+ if (!names.length) {
1065
+ container.innerHTML = '<div class="empty-note">No provider activity in this range yet.</div>';
1066
+ return;
1067
+ }
1068
+ container.appendChild(buildTable(
1069
+ ['Provider', 'Requests', 'Cost', 'Error rate', 'Avg latency'],
1070
+ names.map(function (name) {
1071
+ var p = data.by_provider[name];
1072
+ return [name, formatCount(p.requests), formatUsd(p.cost_usd), formatPercent(p.errorRate), formatMs(p.avgLatencyMs)];
1073
+ }),
1074
+ [false, true, true, true, true]
1075
+ ));
1076
+ }
1077
+
1078
+ // ---- shared table builder (the accessible twin of every chart) ----
1079
+
1080
+ function buildTable(headers, rows, numericCols) {
1081
+ var table = document.createElement('table');
1082
+ table.className = 'data-table';
1083
+ var thead = document.createElement('thead');
1084
+ var headRow = document.createElement('tr');
1085
+ headers.forEach(function (hLabel, i) {
1086
+ var th = document.createElement('th');
1087
+ if (numericCols && numericCols[i]) th.className = 'num';
1088
+ th.textContent = hLabel;
1089
+ headRow.appendChild(th);
1090
+ });
1091
+ thead.appendChild(headRow);
1092
+ table.appendChild(thead);
1093
+ var tbody = document.createElement('tbody');
1094
+ rows.forEach(function (row) {
1095
+ var tr = document.createElement('tr');
1096
+ row.forEach(function (cell, i) {
1097
+ var td = document.createElement('td');
1098
+ if (numericCols && numericCols[i]) td.className = 'num';
1099
+ td.textContent = cell;
1100
+ tr.appendChild(td);
1101
+ });
1102
+ tbody.appendChild(tr);
1103
+ });
1104
+ table.appendChild(tbody);
1105
+ return table;
1106
+ }
1107
+
1108
+ // ---------- boot ----------
1109
+
1110
+ if (getStoredKey()) {
1111
+ loadDashboard(currentDays);
1112
+ } else {
1113
+ showKeyGate('');
1114
+ }
1115
+ })();
1116
+ </script>
1117
+
1118
+ </body>
1119
+ </html>