@lovenyberg/ove 0.7.0 → 0.9.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.
@@ -0,0 +1,716 @@
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.0">
6
+ <title>Ove — Metrics</title>
7
+ <link rel="icon" href="/favicon.ico">
8
+ <style>
9
+ * { margin: 0; padding: 0; box-sizing: border-box; }
10
+
11
+ :root {
12
+ --bg: #1a1a1a;
13
+ --bg-panel: #161616;
14
+ --bg-item: #1e1e1e;
15
+ --bg-item-hover: #252525;
16
+ --border: #2a2a2a;
17
+ --border-light: #333;
18
+ --text: #e0e0e0;
19
+ --text-dim: #777;
20
+ --text-muted: #555;
21
+ --accent: #8ab4f8;
22
+ --green: #4ade80;
23
+ --green-dim: #16361f;
24
+ --red: #f28b82;
25
+ --red-dim: #3b1a1a;
26
+ --amber: #fbbf24;
27
+ --amber-dim: #3b2e0a;
28
+ --cyan: #22d3ee;
29
+ --cyan-dim: #0a2e33;
30
+ --blue: #60a5fa;
31
+ --blue-dim: #152540;
32
+ }
33
+
34
+ body {
35
+ font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
36
+ background: var(--bg);
37
+ color: var(--text);
38
+ height: 100vh;
39
+ display: flex;
40
+ flex-direction: column;
41
+ overflow: hidden;
42
+ }
43
+
44
+ header {
45
+ display: flex;
46
+ align-items: center;
47
+ justify-content: space-between;
48
+ padding: 0.5rem 1rem;
49
+ border-bottom: 1px solid var(--border);
50
+ background: var(--bg-panel);
51
+ flex-shrink: 0;
52
+ }
53
+
54
+ .header-left {
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 1rem;
58
+ }
59
+
60
+ .header-logo {
61
+ width: 24px;
62
+ height: 24px;
63
+ border-radius: 3px;
64
+ object-fit: cover;
65
+ }
66
+
67
+ .header-left h1 {
68
+ font-size: 0.85rem;
69
+ font-weight: 600;
70
+ letter-spacing: 0.05em;
71
+ color: var(--text-dim);
72
+ }
73
+
74
+ .header-left h1 span { color: var(--text); }
75
+
76
+ .nav-link {
77
+ color: var(--text-dim);
78
+ text-decoration: none;
79
+ font-size: 0.7rem;
80
+ padding: 0.2rem 0.5rem;
81
+ border: 1px solid var(--border);
82
+ border-radius: 3px;
83
+ transition: all 0.15s;
84
+ }
85
+ .nav-link:hover { color: var(--text); border-color: var(--border-light); }
86
+
87
+ .header-right {
88
+ display: flex;
89
+ align-items: center;
90
+ gap: 0.75rem;
91
+ }
92
+
93
+ .toggle-wrap {
94
+ display: flex;
95
+ align-items: center;
96
+ gap: 0.4rem;
97
+ font-size: 0.7rem;
98
+ color: var(--text-dim);
99
+ cursor: pointer;
100
+ user-select: none;
101
+ }
102
+
103
+ .toggle-wrap input { display: none; }
104
+
105
+ .toggle-track {
106
+ width: 28px;
107
+ height: 14px;
108
+ background: var(--border);
109
+ border-radius: 7px;
110
+ position: relative;
111
+ transition: background 0.2s;
112
+ }
113
+
114
+ .toggle-track::after {
115
+ content: '';
116
+ position: absolute;
117
+ top: 2px;
118
+ left: 2px;
119
+ width: 10px;
120
+ height: 10px;
121
+ background: var(--text-dim);
122
+ border-radius: 50%;
123
+ transition: all 0.2s;
124
+ }
125
+
126
+ .toggle-wrap input:checked + .toggle-track {
127
+ background: var(--green-dim);
128
+ }
129
+
130
+ .toggle-wrap input:checked + .toggle-track::after {
131
+ left: 16px;
132
+ background: var(--green);
133
+ }
134
+
135
+ .last-updated {
136
+ font-size: 0.6rem;
137
+ color: var(--text-muted);
138
+ font-variant-numeric: tabular-nums;
139
+ }
140
+
141
+ /* Content */
142
+ .content {
143
+ flex: 1;
144
+ overflow-y: auto;
145
+ padding: 1rem;
146
+ scrollbar-width: thin;
147
+ scrollbar-color: var(--border) transparent;
148
+ }
149
+
150
+ .section-header {
151
+ font-size: 0.6rem;
152
+ color: var(--text-muted);
153
+ text-transform: uppercase;
154
+ letter-spacing: 0.1em;
155
+ margin-bottom: 0.5rem;
156
+ display: flex;
157
+ align-items: center;
158
+ gap: 0.5rem;
159
+ }
160
+
161
+ .section-header::after {
162
+ content: '';
163
+ flex: 1;
164
+ height: 1px;
165
+ background: var(--border);
166
+ }
167
+
168
+ /* Metric cards grid */
169
+ .metric-grid {
170
+ display: grid;
171
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
172
+ gap: 0.5rem;
173
+ margin-bottom: 1.5rem;
174
+ }
175
+
176
+ .metric-card {
177
+ background: var(--bg-item);
178
+ border: 1px solid var(--border);
179
+ border-radius: 3px;
180
+ padding: 0.6rem 0.75rem;
181
+ text-align: center;
182
+ }
183
+
184
+ .metric-value {
185
+ font-size: 1.4rem;
186
+ font-weight: 700;
187
+ font-variant-numeric: tabular-nums;
188
+ line-height: 1.2;
189
+ }
190
+
191
+ .metric-value.pending { color: var(--amber); }
192
+ .metric-value.running { color: var(--cyan); }
193
+ .metric-value.completed { color: var(--green); }
194
+ .metric-value.failed { color: var(--red); }
195
+ .metric-value.neutral { color: var(--text); }
196
+ .metric-value.accent { color: var(--accent); }
197
+ .metric-value.error { color: var(--red); }
198
+ .metric-value.good { color: var(--green); }
199
+
200
+ .metric-label {
201
+ font-size: 0.6rem;
202
+ color: var(--text-muted);
203
+ text-transform: uppercase;
204
+ letter-spacing: 0.05em;
205
+ margin-top: 0.15rem;
206
+ }
207
+
208
+ /* Duration bar chart */
209
+ .duration-chart {
210
+ margin-bottom: 1.5rem;
211
+ }
212
+
213
+ .duration-bar-row {
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 0.5rem;
217
+ margin-bottom: 0.35rem;
218
+ }
219
+
220
+ .duration-bar-label {
221
+ font-size: 0.7rem;
222
+ color: var(--accent);
223
+ width: 140px;
224
+ flex-shrink: 0;
225
+ white-space: nowrap;
226
+ overflow: hidden;
227
+ text-overflow: ellipsis;
228
+ text-align: right;
229
+ }
230
+
231
+ .duration-bar-track {
232
+ flex: 1;
233
+ height: 16px;
234
+ background: var(--bg-item);
235
+ border: 1px solid var(--border);
236
+ border-radius: 2px;
237
+ overflow: hidden;
238
+ position: relative;
239
+ }
240
+
241
+ .duration-bar-fill {
242
+ height: 100%;
243
+ background: var(--blue-dim);
244
+ border-right: 2px solid var(--blue);
245
+ transition: width 0.3s ease;
246
+ }
247
+
248
+ .duration-bar-text {
249
+ font-size: 0.6rem;
250
+ color: var(--text-dim);
251
+ width: 80px;
252
+ flex-shrink: 0;
253
+ font-variant-numeric: tabular-nums;
254
+ }
255
+
256
+ .duration-bar-count {
257
+ font-size: 0.55rem;
258
+ color: var(--text-muted);
259
+ width: 50px;
260
+ flex-shrink: 0;
261
+ text-align: right;
262
+ }
263
+
264
+ /* Error rate gauge */
265
+ .gauge-wrap {
266
+ display: flex;
267
+ align-items: center;
268
+ justify-content: center;
269
+ margin-bottom: 1.5rem;
270
+ }
271
+
272
+ .gauge {
273
+ width: 120px;
274
+ height: 120px;
275
+ position: relative;
276
+ }
277
+
278
+ .gauge svg {
279
+ width: 100%;
280
+ height: 100%;
281
+ transform: rotate(-90deg);
282
+ }
283
+
284
+ .gauge-bg {
285
+ fill: none;
286
+ stroke: var(--border);
287
+ stroke-width: 8;
288
+ }
289
+
290
+ .gauge-fill {
291
+ fill: none;
292
+ stroke-width: 8;
293
+ stroke-linecap: round;
294
+ transition: stroke-dashoffset 0.5s ease, stroke 0.3s;
295
+ }
296
+
297
+ .gauge-text {
298
+ position: absolute;
299
+ top: 50%;
300
+ left: 50%;
301
+ transform: translate(-50%, -50%);
302
+ text-align: center;
303
+ }
304
+
305
+ .gauge-percent {
306
+ font-size: 1.2rem;
307
+ font-weight: 700;
308
+ font-variant-numeric: tabular-nums;
309
+ }
310
+
311
+ .gauge-sublabel {
312
+ font-size: 0.55rem;
313
+ color: var(--text-muted);
314
+ text-transform: uppercase;
315
+ letter-spacing: 0.05em;
316
+ }
317
+
318
+ /* Repo breakdown table */
319
+ .repo-table {
320
+ width: 100%;
321
+ border-collapse: collapse;
322
+ margin-bottom: 1.5rem;
323
+ font-size: 0.7rem;
324
+ }
325
+
326
+ .repo-table th {
327
+ text-align: left;
328
+ font-size: 0.6rem;
329
+ color: var(--text-muted);
330
+ text-transform: uppercase;
331
+ letter-spacing: 0.05em;
332
+ padding: 0.4rem 0.6rem;
333
+ border-bottom: 1px solid var(--border);
334
+ font-weight: 600;
335
+ }
336
+
337
+ .repo-table th.num { text-align: right; }
338
+
339
+ .repo-table td {
340
+ padding: 0.4rem 0.6rem;
341
+ border-bottom: 1px solid var(--border);
342
+ font-variant-numeric: tabular-nums;
343
+ }
344
+
345
+ .repo-table td.num { text-align: right; }
346
+
347
+ .repo-table tr:hover td { background: var(--bg-item-hover); }
348
+
349
+ .repo-name { color: var(--accent); }
350
+ .count-pending { color: var(--amber); }
351
+ .count-running { color: var(--cyan); }
352
+ .count-completed { color: var(--green); }
353
+ .count-failed { color: var(--red); }
354
+
355
+ /* Adapter health */
356
+ .adapter-grid {
357
+ display: grid;
358
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
359
+ gap: 0.5rem;
360
+ margin-bottom: 1.5rem;
361
+ }
362
+
363
+ .adapter-card {
364
+ background: var(--bg-item);
365
+ border: 1px solid var(--border);
366
+ border-radius: 3px;
367
+ padding: 0.6rem 0.75rem;
368
+ display: flex;
369
+ align-items: center;
370
+ justify-content: space-between;
371
+ }
372
+
373
+ .adapter-card[data-status="disconnected"] { border-color: var(--red-dim); }
374
+ .adapter-card[data-status="degraded"] { border-color: var(--amber-dim); }
375
+
376
+ .adapter-name {
377
+ font-size: 0.75rem;
378
+ font-weight: 600;
379
+ }
380
+
381
+ .adapter-type {
382
+ font-size: 0.5rem;
383
+ color: var(--text-muted);
384
+ text-transform: uppercase;
385
+ }
386
+
387
+ .badge {
388
+ font-size: 0.55rem;
389
+ padding: 0.1rem 0.35rem;
390
+ border-radius: 2px;
391
+ font-weight: 600;
392
+ letter-spacing: 0.03em;
393
+ text-transform: uppercase;
394
+ flex-shrink: 0;
395
+ }
396
+
397
+ .badge-connected { background: var(--green-dim); color: var(--green); }
398
+ .badge-disconnected { background: var(--red-dim); color: var(--red); }
399
+ .badge-degraded { background: var(--amber-dim); color: var(--amber); }
400
+ .badge-unknown { background: #222; color: var(--text-muted); }
401
+
402
+ .empty-state {
403
+ display: flex;
404
+ align-items: center;
405
+ justify-content: center;
406
+ height: 50vh;
407
+ color: var(--text-muted);
408
+ font-size: 0.8rem;
409
+ }
410
+
411
+ .uptime-row {
412
+ font-size: 0.65rem;
413
+ color: var(--text-muted);
414
+ margin-bottom: 1rem;
415
+ }
416
+ .uptime-value { color: var(--text-dim); }
417
+
418
+ ::-webkit-scrollbar { width: 6px; }
419
+ ::-webkit-scrollbar-track { background: transparent; }
420
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
421
+ ::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
422
+ </style>
423
+ </head>
424
+ <body>
425
+ <header>
426
+ <div class="header-left">
427
+ <img src="/logo.png" class="header-logo" alt="Ove">
428
+ <h1><span>ove</span> metrics</h1>
429
+ <a href="/" class="nav-link">chat</a>
430
+ <a href="/trace" class="nav-link">traces</a>
431
+ <a href="/status" class="nav-link">status</a>
432
+ </div>
433
+ <div class="header-right">
434
+ <span class="last-updated" id="lastUpdated"></span>
435
+ <label class="toggle-wrap">
436
+ <input type="checkbox" id="autoRefresh" checked />
437
+ <div class="toggle-track"></div>
438
+ auto-refresh
439
+ </label>
440
+ </div>
441
+ </header>
442
+
443
+ <div class="content" id="content">
444
+ <div class="empty-state" id="loading">Loading metrics...</div>
445
+ </div>
446
+
447
+ <script>
448
+ var API_KEY = localStorage.getItem("ove-api-key") || prompt("API Key:");
449
+ if (API_KEY) localStorage.setItem("ove-api-key", API_KEY);
450
+
451
+ var contentEl = document.getElementById("content");
452
+ var loadingEl = document.getElementById("loading");
453
+ var lastUpdatedEl = document.getElementById("lastUpdated");
454
+ var autoRefreshEl = document.getElementById("autoRefresh");
455
+ var refreshTimer = null;
456
+ var SVG_NS = "http://www.w3.org/2000/svg";
457
+
458
+ function apiHeaders() { return { "X-API-Key": API_KEY }; }
459
+
460
+ function el(tag, cls, text) {
461
+ var e = document.createElement(tag);
462
+ if (cls) e.className = cls;
463
+ if (text != null) e.textContent = text;
464
+ return e;
465
+ }
466
+
467
+ function svgEl(tag, attrs) {
468
+ var e = document.createElementNS(SVG_NS, tag);
469
+ if (attrs) {
470
+ for (var k in attrs) {
471
+ e.setAttribute(k, String(attrs[k]));
472
+ }
473
+ }
474
+ return e;
475
+ }
476
+
477
+ function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); }
478
+
479
+ function fmtUptime(secs) {
480
+ var d = Math.floor(secs / 86400);
481
+ var h = Math.floor((secs % 86400) / 3600);
482
+ var m = Math.floor((secs % 3600) / 60);
483
+ var s = Math.floor(secs % 60);
484
+ var parts = [];
485
+ if (d > 0) parts.push(d + "d");
486
+ if (h > 0) parts.push(h + "h");
487
+ if (m > 0) parts.push(m + "m");
488
+ parts.push(s + "s");
489
+ return parts.join(" ");
490
+ }
491
+
492
+ function fmtDuration(ms) {
493
+ if (ms < 1000) return ms + "ms";
494
+ var secs = ms / 1000;
495
+ if (secs < 60) return secs.toFixed(1) + "s";
496
+ var mins = secs / 60;
497
+ if (mins < 60) return mins.toFixed(1) + "m";
498
+ var hrs = mins / 60;
499
+ return hrs.toFixed(1) + "h";
500
+ }
501
+
502
+ function buildGauge(errorRate) {
503
+ var radius = 48;
504
+ var circumference = 2 * Math.PI * radius;
505
+ var errorPct = Math.min(errorRate, 1);
506
+ var offset = circumference - (errorPct * circumference);
507
+ var strokeColor = errorRate > 0.1 ? "var(--red)" : errorRate > 0.05 ? "var(--amber)" : "var(--green)";
508
+ var errPct = (errorRate * 100).toFixed(1);
509
+
510
+ var gaugeDiv = el("div", "gauge");
511
+
512
+ var svg = svgEl("svg", { viewBox: "0 0 120 120" });
513
+ var bgCircle = svgEl("circle", { class: "gauge-bg", cx: "60", cy: "60", r: String(radius) });
514
+ svg.appendChild(bgCircle);
515
+ var fillCircle = svgEl("circle", {
516
+ class: "gauge-fill", cx: "60", cy: "60", r: String(radius),
517
+ stroke: strokeColor,
518
+ "stroke-dasharray": String(circumference),
519
+ "stroke-dashoffset": String(offset)
520
+ });
521
+ svg.appendChild(fillCircle);
522
+ gaugeDiv.appendChild(svg);
523
+
524
+ var gaugeText = el("div", "gauge-text");
525
+ var gPct = el("div", "gauge-percent", errPct + "%");
526
+ gPct.style.color = strokeColor;
527
+ gaugeText.appendChild(gPct);
528
+ gaugeText.appendChild(el("div", "gauge-sublabel", "error rate"));
529
+ gaugeDiv.appendChild(gaugeText);
530
+
531
+ return gaugeDiv;
532
+ }
533
+
534
+ function renderMetrics(data) {
535
+ clear(contentEl);
536
+
537
+ // Uptime
538
+ var uptimeRow = el("div", "uptime-row");
539
+ uptimeRow.appendChild(document.createTextNode("uptime "));
540
+ uptimeRow.appendChild(el("span", "uptime-value", fmtUptime(data.uptime)));
541
+ contentEl.appendChild(uptimeRow);
542
+
543
+ // Queue depth
544
+ contentEl.appendChild(el("div", "section-header", "Queue Depth"));
545
+ var qGrid = el("div", "metric-grid");
546
+ var statuses = ["pending", "running", "completed", "failed"];
547
+ for (var i = 0; i < statuses.length; i++) {
548
+ var s = statuses[i];
549
+ var card = el("div", "metric-card");
550
+ card.appendChild(el("div", "metric-value " + s, String(data.counts[s] || 0)));
551
+ card.appendChild(el("div", "metric-label", s));
552
+ qGrid.appendChild(card);
553
+ }
554
+ contentEl.appendChild(qGrid);
555
+
556
+ // Throughput + Error rate
557
+ contentEl.appendChild(el("div", "section-header", "Throughput & Error Rate"));
558
+ var tGrid = el("div", "metric-grid");
559
+
560
+ var t1 = el("div", "metric-card");
561
+ t1.appendChild(el("div", "metric-value accent", String(data.throughput.lastHour)));
562
+ t1.appendChild(el("div", "metric-label", "last hour"));
563
+ tGrid.appendChild(t1);
564
+
565
+ var t2 = el("div", "metric-card");
566
+ t2.appendChild(el("div", "metric-value accent", String(data.throughput.last24h)));
567
+ t2.appendChild(el("div", "metric-label", "last 24h"));
568
+ tGrid.appendChild(t2);
569
+
570
+ var errPct = (data.errorRate * 100).toFixed(1);
571
+ var errCard = el("div", "metric-card");
572
+ var errClass = data.errorRate > 0.1 ? "error" : data.errorRate > 0 ? "failed" : "good";
573
+ errCard.appendChild(el("div", "metric-value " + errClass, errPct + "%"));
574
+ errCard.appendChild(el("div", "metric-label", "error rate"));
575
+ tGrid.appendChild(errCard);
576
+
577
+ contentEl.appendChild(tGrid);
578
+
579
+ // Error rate gauge
580
+ var gaugeSection = el("div", "gauge-wrap");
581
+ gaugeSection.appendChild(buildGauge(data.errorRate));
582
+ contentEl.appendChild(gaugeSection);
583
+
584
+ // Duration by repo (bar chart)
585
+ if (data.avgDurationByRepo && data.avgDurationByRepo.length > 0) {
586
+ contentEl.appendChild(el("div", "section-header", "Avg Task Duration by Repo"));
587
+ var chartDiv = el("div", "duration-chart");
588
+ var maxMs = 0;
589
+ for (var i = 0; i < data.avgDurationByRepo.length; i++) {
590
+ if (data.avgDurationByRepo[i].avgMs > maxMs) maxMs = data.avgDurationByRepo[i].avgMs;
591
+ }
592
+ for (var i = 0; i < data.avgDurationByRepo.length; i++) {
593
+ var d = data.avgDurationByRepo[i];
594
+ var row = el("div", "duration-bar-row");
595
+ row.appendChild(el("div", "duration-bar-label", d.repo));
596
+ var track = el("div", "duration-bar-track");
597
+ var fill = el("div", "duration-bar-fill");
598
+ var pct = maxMs > 0 ? (d.avgMs / maxMs * 100) : 0;
599
+ fill.style.width = Math.max(pct, 2) + "%";
600
+ track.appendChild(fill);
601
+ row.appendChild(track);
602
+ row.appendChild(el("div", "duration-bar-text", fmtDuration(d.avgMs)));
603
+ row.appendChild(el("div", "duration-bar-count", d.count + " tasks"));
604
+ chartDiv.appendChild(row);
605
+ }
606
+ contentEl.appendChild(chartDiv);
607
+ }
608
+
609
+ // Per-repo breakdown table
610
+ if (data.repoBreakdown && data.repoBreakdown.length > 0) {
611
+ contentEl.appendChild(el("div", "section-header", "Per-Repo Breakdown"));
612
+ var table = el("table", "repo-table");
613
+ var thead = document.createElement("thead");
614
+ var headRow = document.createElement("tr");
615
+ var cols = ["Repo", "Pending", "Running", "Completed", "Failed", "Total"];
616
+ for (var i = 0; i < cols.length; i++) {
617
+ var th = el("th", i > 0 ? "num" : "", cols[i]);
618
+ headRow.appendChild(th);
619
+ }
620
+ thead.appendChild(headRow);
621
+ table.appendChild(thead);
622
+
623
+ var tbody = document.createElement("tbody");
624
+ for (var i = 0; i < data.repoBreakdown.length; i++) {
625
+ var r = data.repoBreakdown[i];
626
+ var tr = document.createElement("tr");
627
+ tr.appendChild(el("td", "repo-name", r.repo));
628
+
629
+ var td1 = el("td", "num");
630
+ td1.appendChild(el("span", "count-pending", String(r.pending)));
631
+ tr.appendChild(td1);
632
+
633
+ var td2 = el("td", "num");
634
+ td2.appendChild(el("span", "count-running", String(r.running)));
635
+ tr.appendChild(td2);
636
+
637
+ var td3 = el("td", "num");
638
+ td3.appendChild(el("span", "count-completed", String(r.completed)));
639
+ tr.appendChild(td3);
640
+
641
+ var td4 = el("td", "num");
642
+ td4.appendChild(el("span", "count-failed", String(r.failed)));
643
+ tr.appendChild(td4);
644
+
645
+ var total = r.pending + r.running + r.completed + r.failed;
646
+ tr.appendChild(el("td", "num", String(total)));
647
+
648
+ tbody.appendChild(tr);
649
+ }
650
+ table.appendChild(tbody);
651
+ contentEl.appendChild(table);
652
+ }
653
+
654
+ // Adapter health
655
+ if (data.adapters && data.adapters.length > 0) {
656
+ contentEl.appendChild(el("div", "section-header", "Adapter Health"));
657
+ var aGrid = el("div", "adapter-grid");
658
+ for (var i = 0; i < data.adapters.length; i++) {
659
+ var a = data.adapters[i];
660
+ var card = el("div", "adapter-card");
661
+ card.dataset.status = a.status;
662
+ var left = el("div");
663
+ left.appendChild(el("div", "adapter-name", a.name));
664
+ left.appendChild(el("div", "adapter-type", a.type));
665
+ card.appendChild(left);
666
+ card.appendChild(el("span", "badge badge-" + a.status, a.status));
667
+ aGrid.appendChild(card);
668
+ }
669
+ contentEl.appendChild(aGrid);
670
+ }
671
+
672
+ lastUpdatedEl.textContent = "updated " + new Date().toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
673
+ }
674
+
675
+ async function fetchMetrics() {
676
+ try {
677
+ var res = await fetch("/api/metrics?key=" + encodeURIComponent(API_KEY), { headers: apiHeaders() });
678
+ if (!res.ok) {
679
+ if (loadingEl && loadingEl.parentNode) loadingEl.textContent = "Error: " + res.status;
680
+ return;
681
+ }
682
+ var data = await res.json();
683
+ renderMetrics(data);
684
+ } catch (err) {
685
+ if (loadingEl && loadingEl.parentNode) {
686
+ loadingEl.textContent = "Error: " + err.message;
687
+ }
688
+ }
689
+ }
690
+
691
+ function startAutoRefresh() {
692
+ if (refreshTimer) clearInterval(refreshTimer);
693
+ refreshTimer = setInterval(fetchMetrics, 5000);
694
+ }
695
+
696
+ function stopAutoRefresh() {
697
+ if (refreshTimer) {
698
+ clearInterval(refreshTimer);
699
+ refreshTimer = null;
700
+ }
701
+ }
702
+
703
+ autoRefreshEl.addEventListener("change", function () {
704
+ if (autoRefreshEl.checked) {
705
+ startAutoRefresh();
706
+ } else {
707
+ stopAutoRefresh();
708
+ }
709
+ });
710
+
711
+ // Init
712
+ fetchMetrics();
713
+ startAutoRefresh();
714
+ </script>
715
+ </body>
716
+ </html>