@momentumcms/plugins-otel 0.5.3 → 0.5.5

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,788 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __decorateClass = (decorators, target, key, kind) => {
12
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
13
+ for (var i = decorators.length - 1, decorator; i >= 0; i--)
14
+ if (decorator = decorators[i])
15
+ result = (kind ? decorator(target, key, result) : decorator(result)) || result;
16
+ if (kind && result)
17
+ __defProp(target, key, result);
18
+ return result;
19
+ };
20
+
21
+ // libs/plugins/otel/src/lib/dashboard/otel.service.ts
22
+ import { DOCUMENT } from "@angular/common";
23
+ import { inject, Injectable, signal } from "@angular/core";
24
+ var DEFAULT_POLL_INTERVAL_MS, OtelService;
25
+ var init_otel_service = __esm({
26
+ "libs/plugins/otel/src/lib/dashboard/otel.service.ts"() {
27
+ "use strict";
28
+ DEFAULT_POLL_INTERVAL_MS = 5e3;
29
+ OtelService = class {
30
+ constructor() {
31
+ this.loading = signal(false);
32
+ this.error = signal(null);
33
+ this.summary = signal(null);
34
+ this.live = signal(false);
35
+ this.history = signal([]);
36
+ this.historyTotal = signal(0);
37
+ this.historyLoading = signal(false);
38
+ this.exporting = signal(false);
39
+ this.purging = signal(false);
40
+ this.document = inject(DOCUMENT);
41
+ this.window = this.document.defaultView;
42
+ this.pollTimer = null;
43
+ }
44
+ ngOnDestroy() {
45
+ this.stopPolling();
46
+ }
47
+ toggleLive(intervalMs = DEFAULT_POLL_INTERVAL_MS) {
48
+ if (this.live()) {
49
+ this.stopPolling();
50
+ } else {
51
+ this.startPolling(intervalMs);
52
+ }
53
+ }
54
+ async fetchSummary() {
55
+ this.loading.set(true);
56
+ this.error.set(null);
57
+ try {
58
+ const response = await fetch("/api/otel/summary");
59
+ if (!response.ok) {
60
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
61
+ }
62
+ const data = await response.json();
63
+ this.summary.set(data);
64
+ } catch (err) {
65
+ const message = err instanceof Error ? err.message : String(err);
66
+ this.error.set(message);
67
+ } finally {
68
+ this.loading.set(false);
69
+ }
70
+ }
71
+ async fetchHistory(from, to) {
72
+ this.historyLoading.set(true);
73
+ try {
74
+ const params = new URLSearchParams();
75
+ if (from)
76
+ params.set("from", from);
77
+ if (to)
78
+ params.set("to", to);
79
+ params.set("limit", "100");
80
+ const response = await fetch(`/api/otel/history?${params.toString()}`);
81
+ if (!response.ok) {
82
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
83
+ }
84
+ const data = await response.json();
85
+ this.history.set(Array.isArray(data.snapshots) ? data.snapshots : []);
86
+ this.historyTotal.set(typeof data.total === "number" ? data.total : 0);
87
+ } catch (err) {
88
+ const message = err instanceof Error ? err.message : String(err);
89
+ this.error.set(message);
90
+ } finally {
91
+ this.historyLoading.set(false);
92
+ }
93
+ }
94
+ exportCsv(from, to) {
95
+ this.exporting.set(true);
96
+ const params = new URLSearchParams();
97
+ if (from)
98
+ params.set("from", from);
99
+ if (to)
100
+ params.set("to", to);
101
+ const url = `/api/otel/export?${params.toString()}`;
102
+ const link = this.document.createElement("a");
103
+ link.href = url;
104
+ link.download = "";
105
+ link.style.display = "none";
106
+ this.document.body.appendChild(link);
107
+ link.click();
108
+ this.document.body.removeChild(link);
109
+ this.window?.setTimeout(() => this.exporting.set(false), 2e3);
110
+ }
111
+ async purgeHistory() {
112
+ this.purging.set(true);
113
+ try {
114
+ const response = await fetch("/api/otel/history", { method: "DELETE" });
115
+ if (!response.ok) {
116
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
117
+ }
118
+ this.history.set([]);
119
+ this.historyTotal.set(0);
120
+ } catch (err) {
121
+ const message = err instanceof Error ? err.message : String(err);
122
+ this.error.set(message);
123
+ } finally {
124
+ this.purging.set(false);
125
+ }
126
+ }
127
+ startPolling(intervalMs) {
128
+ this.stopPolling();
129
+ this.live.set(true);
130
+ this.pollTimer = this.window?.setInterval(() => void this.fetchSummary(), intervalMs) ?? null;
131
+ }
132
+ stopPolling() {
133
+ if (this.pollTimer != null) {
134
+ this.window?.clearInterval(this.pollTimer);
135
+ this.pollTimer = null;
136
+ }
137
+ this.live.set(false);
138
+ }
139
+ };
140
+ OtelService = __decorateClass([
141
+ Injectable({ providedIn: "root" })
142
+ ], OtelService);
143
+ }
144
+ });
145
+
146
+ // libs/plugins/otel/src/lib/dashboard/otel-dashboard.page.ts
147
+ var otel_dashboard_page_exports = {};
148
+ __export(otel_dashboard_page_exports, {
149
+ OtelDashboardPage: () => OtelDashboardPage
150
+ });
151
+ import {
152
+ Component,
153
+ ChangeDetectionStrategy,
154
+ inject as inject2,
155
+ PLATFORM_ID,
156
+ signal as signal2
157
+ } from "@angular/core";
158
+ import { DOCUMENT as DOCUMENT2, isPlatformBrowser } from "@angular/common";
159
+ import {
160
+ Card,
161
+ CardHeader,
162
+ CardTitle,
163
+ CardDescription,
164
+ CardContent,
165
+ Badge,
166
+ Skeleton
167
+ } from "@momentumcms/ui";
168
+ import { NgIcon, provideIcons } from "@ng-icons/core";
169
+ import {
170
+ heroSignal,
171
+ heroArrowPath,
172
+ heroClock,
173
+ heroServerStack,
174
+ heroChartBarSquare,
175
+ heroCpuChip,
176
+ heroDocumentText,
177
+ heroEye,
178
+ heroArrowDownTray,
179
+ heroTrash
180
+ } from "@ng-icons/heroicons/outline";
181
+ var OtelDashboardPage;
182
+ var init_otel_dashboard_page = __esm({
183
+ "libs/plugins/otel/src/lib/dashboard/otel-dashboard.page.ts"() {
184
+ "use strict";
185
+ init_otel_service();
186
+ OtelDashboardPage = class {
187
+ constructor() {
188
+ this.otel = inject2(OtelService);
189
+ this.platformId = inject2(PLATFORM_ID);
190
+ this.window = inject2(DOCUMENT2).defaultView;
191
+ this.selectedRange = signal2("day");
192
+ this.confirmingPurge = signal2(false);
193
+ this.purgeResetTimer = null;
194
+ this.historyRanges = [
195
+ { key: "hour", label: "Last Hour" },
196
+ { key: "day", label: "Last 24h" },
197
+ { key: "week", label: "Last 7 Days" }
198
+ ];
199
+ }
200
+ ngOnInit() {
201
+ if (!isPlatformBrowser(this.platformId))
202
+ return;
203
+ void this.refresh();
204
+ void this.loadHistory();
205
+ }
206
+ ngOnDestroy() {
207
+ if (this.otel.live()) {
208
+ this.otel.toggleLive();
209
+ }
210
+ if (this.purgeResetTimer != null) {
211
+ this.window?.clearTimeout(this.purgeResetTimer);
212
+ }
213
+ }
214
+ async refresh() {
215
+ await this.otel.fetchSummary();
216
+ }
217
+ selectRange(range) {
218
+ this.selectedRange.set(range);
219
+ void this.loadHistory();
220
+ }
221
+ exportCsv() {
222
+ const { from } = this.getTimeRange();
223
+ this.otel.exportCsv(from);
224
+ }
225
+ confirmPurge() {
226
+ if (this.purgeResetTimer != null) {
227
+ this.window?.clearTimeout(this.purgeResetTimer);
228
+ this.purgeResetTimer = null;
229
+ }
230
+ if (this.confirmingPurge()) {
231
+ this.confirmingPurge.set(false);
232
+ void this.otel.purgeHistory();
233
+ } else {
234
+ this.confirmingPurge.set(true);
235
+ this.purgeResetTimer = this.window?.setTimeout(() => {
236
+ this.confirmingPurge.set(false);
237
+ this.purgeResetTimer = null;
238
+ }, 3e3) ?? null;
239
+ }
240
+ }
241
+ formatUptime(seconds) {
242
+ if (seconds < 60)
243
+ return `${seconds}s`;
244
+ if (seconds < 3600)
245
+ return `${Math.floor(seconds / 60)}m`;
246
+ if (seconds < 86400)
247
+ return `${Math.floor(seconds / 3600)}h ${Math.floor(seconds % 3600 / 60)}m`;
248
+ return `${Math.floor(seconds / 86400)}d ${Math.floor(seconds % 86400 / 3600)}h`;
249
+ }
250
+ errorRate(s) {
251
+ if (s.requestMetrics.totalRequests === 0)
252
+ return "0";
253
+ return (s.requestMetrics.errorCount / s.requestMetrics.totalRequests * 100).toFixed(1);
254
+ }
255
+ methodEntries(s) {
256
+ return Object.entries(s.requestMetrics.byMethod).map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count);
257
+ }
258
+ statusEntries(s) {
259
+ return Object.entries(s.requestMetrics.byStatusCode).map(([name, count]) => ({ name, count })).sort((a, b) => Number(a.name) - Number(b.name));
260
+ }
261
+ statusVariant(status) {
262
+ const code = Number(status);
263
+ if (code >= 500)
264
+ return "destructive";
265
+ if (code >= 400)
266
+ return "warning";
267
+ if (code >= 200 && code < 300)
268
+ return "success";
269
+ return "secondary";
270
+ }
271
+ spanStatusVariant(span) {
272
+ return span.status === "ok" ? "success" : "destructive";
273
+ }
274
+ formatTime(timestamp) {
275
+ const now = Date.now();
276
+ const then = new Date(timestamp).getTime();
277
+ const diff = now - then;
278
+ if (diff < 6e4)
279
+ return "Just now";
280
+ if (diff < 36e5)
281
+ return `${Math.floor(diff / 6e4)}m ago`;
282
+ if (diff < 864e5)
283
+ return `${Math.floor(diff / 36e5)}h ago`;
284
+ return `${Math.floor(diff / 864e5)}d ago`;
285
+ }
286
+ formatSnapshotTime(timestamp) {
287
+ if (!timestamp)
288
+ return "\u2014";
289
+ const date = new Date(timestamp);
290
+ return date.toLocaleString(void 0, {
291
+ month: "short",
292
+ day: "numeric",
293
+ hour: "2-digit",
294
+ minute: "2-digit"
295
+ });
296
+ }
297
+ truncateId(id) {
298
+ if (!id || id.length <= 12)
299
+ return id || "\u2014";
300
+ return id.slice(0, 8) + "...";
301
+ }
302
+ async loadHistory() {
303
+ const { from } = this.getTimeRange();
304
+ await this.otel.fetchHistory(from);
305
+ }
306
+ getTimeRange() {
307
+ const now = Date.now();
308
+ const range = this.selectedRange();
309
+ let ms = 24 * 60 * 60 * 1e3;
310
+ if (range === "hour")
311
+ ms = 60 * 60 * 1e3;
312
+ else if (range === "week")
313
+ ms = 7 * 24 * 60 * 60 * 1e3;
314
+ return { from: new Date(now - ms).toISOString() };
315
+ }
316
+ };
317
+ OtelDashboardPage = __decorateClass([
318
+ Component({
319
+ selector: "mcms-otel-dashboard",
320
+ imports: [
321
+ Card,
322
+ CardHeader,
323
+ CardTitle,
324
+ CardDescription,
325
+ CardContent,
326
+ Badge,
327
+ Skeleton,
328
+ NgIcon
329
+ ],
330
+ providers: [
331
+ provideIcons({
332
+ heroSignal,
333
+ heroArrowPath,
334
+ heroClock,
335
+ heroServerStack,
336
+ heroChartBarSquare,
337
+ heroCpuChip,
338
+ heroDocumentText,
339
+ heroEye,
340
+ heroArrowDownTray,
341
+ heroTrash
342
+ })
343
+ ],
344
+ changeDetection: ChangeDetectionStrategy.OnPush,
345
+ host: { class: "block max-w-6xl" },
346
+ template: `
347
+ <header class="mb-10">
348
+ <div class="flex items-center justify-between">
349
+ <div>
350
+ <h1 class="text-4xl font-bold tracking-tight text-foreground">Observability</h1>
351
+ <p class="text-muted-foreground mt-3 text-lg">
352
+ System health, request metrics, and trace visibility
353
+ </p>
354
+ </div>
355
+ <div class="flex items-center gap-3">
356
+ <button
357
+ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md
358
+ transition-colors cursor-pointer
359
+ focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
360
+ [class]="otel.live()
361
+ ? 'bg-green-600 text-white hover:bg-green-700'
362
+ : 'border border-border bg-background text-foreground hover:bg-muted'"
363
+ (click)="otel.toggleLive()"
364
+ [attr.aria-label]="otel.live() ? 'Disable live updates' : 'Enable live updates (every 5s)'"
365
+ [attr.aria-pressed]="otel.live()"
366
+ >
367
+ <span
368
+ class="inline-block h-2 w-2 rounded-full"
369
+ [class]="otel.live() ? 'bg-white animate-pulse' : 'bg-muted-foreground'"
370
+ aria-hidden="true"
371
+ ></span>
372
+ Live
373
+ </button>
374
+ <button
375
+ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md
376
+ bg-primary text-primary-foreground hover:bg-primary/90 transition-colors cursor-pointer
377
+ focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary
378
+ disabled:opacity-50 disabled:cursor-not-allowed"
379
+ (click)="refresh()"
380
+ [attr.aria-label]="otel.loading() ? 'Refreshing observability data' : 'Refresh observability data'"
381
+ [disabled]="otel.loading()"
382
+ >
383
+ <ng-icon name="heroArrowPath" size="16" aria-hidden="true" />
384
+ Refresh
385
+ </button>
386
+ </div>
387
+ </div>
388
+ </header>
389
+
390
+ <div aria-live="polite" class="sr-only">
391
+ @if (otel.loading()) { Loading observability data... }
392
+ </div>
393
+
394
+ <!-- System Health -->
395
+ <section class="mb-10" aria-labelledby="system-health-heading">
396
+ <h2 id="system-health-heading" class="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
397
+ System Health
398
+ </h2>
399
+ @if (otel.loading() && !otel.summary()) {
400
+ <div class="grid grid-cols-1 sm:grid-cols-3 gap-6" aria-busy="true" aria-label="Loading system health data">
401
+ @for (i of [1, 2, 3]; track i) {
402
+ <mcms-card>
403
+ <mcms-card-header>
404
+ <mcms-skeleton class="h-4 w-24" />
405
+ <mcms-skeleton class="h-8 w-16 mt-2" />
406
+ </mcms-card-header>
407
+ </mcms-card>
408
+ }
409
+ </div>
410
+ } @else if (otel.summary(); as s) {
411
+ <div class="grid grid-cols-1 sm:grid-cols-3 gap-6">
412
+ <mcms-card>
413
+ <mcms-card-header>
414
+ <div class="flex items-center justify-between">
415
+ <mcms-card-description>Uptime</mcms-card-description>
416
+ <ng-icon name="heroClock" class="text-muted-foreground" size="20" aria-hidden="true" />
417
+ </div>
418
+ <mcms-card-title>
419
+ <span class="text-3xl font-bold">{{ formatUptime(s.uptime) }}</span>
420
+ </mcms-card-title>
421
+ </mcms-card-header>
422
+ </mcms-card>
423
+
424
+ <mcms-card>
425
+ <mcms-card-header>
426
+ <div class="flex items-center justify-between">
427
+ <mcms-card-description>Active Requests</mcms-card-description>
428
+ <ng-icon name="heroSignal" class="text-muted-foreground" size="20" aria-hidden="true" />
429
+ </div>
430
+ <mcms-card-title>
431
+ <span class="text-3xl font-bold">{{ s.activeRequests }}</span>
432
+ </mcms-card-title>
433
+ </mcms-card-header>
434
+ </mcms-card>
435
+
436
+ <mcms-card>
437
+ <mcms-card-header>
438
+ <div class="flex items-center justify-between">
439
+ <mcms-card-description>Memory Usage</mcms-card-description>
440
+ <ng-icon name="heroCpuChip" class="text-muted-foreground" size="20" aria-hidden="true" />
441
+ </div>
442
+ <mcms-card-title>
443
+ <span class="text-3xl font-bold">{{ s.memoryUsageMb }} MB</span>
444
+ </mcms-card-title>
445
+ </mcms-card-header>
446
+ </mcms-card>
447
+ </div>
448
+ } @else if (otel.error(); as err) {
449
+ <div role="alert">
450
+ <mcms-card>
451
+ <mcms-card-header>
452
+ <mcms-card-title>Error loading observability data</mcms-card-title>
453
+ <mcms-card-description>{{ err }}</mcms-card-description>
454
+ </mcms-card-header>
455
+ <mcms-card-content>
456
+ <button
457
+ class="text-sm text-primary hover:underline cursor-pointer
458
+ focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
459
+ (click)="refresh()"
460
+ >
461
+ Try again
462
+ </button>
463
+ </mcms-card-content>
464
+ </mcms-card>
465
+ </div>
466
+ }
467
+ </section>
468
+
469
+ <!-- Request Metrics -->
470
+ @if (otel.summary(); as s) {
471
+ <section class="mb-10" aria-labelledby="request-metrics-heading">
472
+ <h2 id="request-metrics-heading" class="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
473
+ Request Metrics
474
+ </h2>
475
+ <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
476
+ <mcms-card>
477
+ <mcms-card-header>
478
+ <div class="flex items-center justify-between">
479
+ <mcms-card-description>Total Requests</mcms-card-description>
480
+ <ng-icon name="heroChartBarSquare" class="text-muted-foreground" size="20" aria-hidden="true" />
481
+ </div>
482
+ <mcms-card-title>
483
+ <span class="text-3xl font-bold">{{ s.requestMetrics.totalRequests }}</span>
484
+ </mcms-card-title>
485
+ </mcms-card-header>
486
+ </mcms-card>
487
+
488
+ <mcms-card>
489
+ <mcms-card-header>
490
+ <mcms-card-description>Avg Duration</mcms-card-description>
491
+ <mcms-card-title>
492
+ <span class="text-3xl font-bold">{{ s.requestMetrics.avgDurationMs }}ms</span>
493
+ </mcms-card-title>
494
+ </mcms-card-header>
495
+ </mcms-card>
496
+
497
+ <mcms-card>
498
+ <mcms-card-header>
499
+ <mcms-card-description>Errors</mcms-card-description>
500
+ <mcms-card-title>
501
+ <span class="text-3xl font-bold">{{ s.requestMetrics.errorCount }}</span>
502
+ </mcms-card-title>
503
+ </mcms-card-header>
504
+ </mcms-card>
505
+
506
+ <mcms-card>
507
+ <mcms-card-header>
508
+ <mcms-card-description>Error Rate</mcms-card-description>
509
+ <mcms-card-title>
510
+ <span class="text-3xl font-bold">{{ errorRate(s) }}%</span>
511
+ </mcms-card-title>
512
+ </mcms-card-header>
513
+ </mcms-card>
514
+ </div>
515
+
516
+ <!-- By Method + By Status Code -->
517
+ @if (methodEntries(s).length > 0 || statusEntries(s).length > 0) {
518
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
519
+ @if (methodEntries(s).length > 0) {
520
+ <mcms-card>
521
+ <mcms-card-header>
522
+ <mcms-card-title>By Method</mcms-card-title>
523
+ </mcms-card-header>
524
+ <mcms-card-content>
525
+ <div class="space-y-2">
526
+ @for (entry of methodEntries(s); track entry.name) {
527
+ <div class="flex items-center justify-between">
528
+ <span class="text-sm font-mono text-foreground">{{ entry.name }}</span>
529
+ <mcms-badge variant="secondary">{{ entry.count }}</mcms-badge>
530
+ </div>
531
+ }
532
+ </div>
533
+ </mcms-card-content>
534
+ </mcms-card>
535
+ }
536
+
537
+ @if (statusEntries(s).length > 0) {
538
+ <mcms-card>
539
+ <mcms-card-header>
540
+ <mcms-card-title>By Status Code</mcms-card-title>
541
+ </mcms-card-header>
542
+ <mcms-card-content>
543
+ <div class="space-y-2">
544
+ @for (entry of statusEntries(s); track entry.name) {
545
+ <div class="flex items-center justify-between">
546
+ <span class="text-sm font-mono text-foreground">{{ entry.name }}</span>
547
+ <mcms-badge [variant]="statusVariant(entry.name)">{{ entry.count }}</mcms-badge>
548
+ </div>
549
+ }
550
+ </div>
551
+ </mcms-card-content>
552
+ </mcms-card>
553
+ }
554
+ </div>
555
+ }
556
+ </section>
557
+
558
+ <!-- Collection Operations -->
559
+ @if (s.collectionMetrics.length > 0) {
560
+ <section class="mb-10" aria-labelledby="collection-ops-heading">
561
+ <h2 id="collection-ops-heading" class="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
562
+ Collection Operations
563
+ </h2>
564
+ <div class="border border-border rounded-lg overflow-hidden">
565
+ <div class="overflow-x-auto">
566
+ <table class="w-full text-sm" aria-labelledby="collection-ops-heading">
567
+ <thead>
568
+ <tr class="border-b border-border bg-muted/50">
569
+ <th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
570
+ Collection
571
+ </th>
572
+ <th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
573
+ Creates
574
+ </th>
575
+ <th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
576
+ Updates
577
+ </th>
578
+ <th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
579
+ Deletes
580
+ </th>
581
+ <th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
582
+ Avg Duration
583
+ </th>
584
+ </tr>
585
+ </thead>
586
+ <tbody>
587
+ @for (col of s.collectionMetrics; track col.collection) {
588
+ <tr class="border-b border-border last:border-0 hover:bg-muted/30 transition-colors">
589
+ <td class="px-4 py-3 font-medium">
590
+ <mcms-badge variant="outline">{{ col.collection }}</mcms-badge>
591
+ </td>
592
+ <td class="px-4 py-3">{{ col.creates }}</td>
593
+ <td class="px-4 py-3">{{ col.updates }}</td>
594
+ <td class="px-4 py-3">{{ col.deletes }}</td>
595
+ <td class="px-4 py-3 text-muted-foreground">{{ col.avgDurationMs }}ms</td>
596
+ </tr>
597
+ }
598
+ </tbody>
599
+ </table>
600
+ </div>
601
+ </div>
602
+ </section>
603
+ }
604
+
605
+ <!-- Recent Traces -->
606
+ @if (s.recentSpans.length > 0) {
607
+ <section class="mb-10" aria-labelledby="recent-traces-heading">
608
+ <h2 id="recent-traces-heading" class="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
609
+ Recent Traces
610
+ </h2>
611
+ <div class="border border-border rounded-lg overflow-hidden">
612
+ <div class="overflow-x-auto">
613
+ <table class="w-full text-sm" aria-labelledby="recent-traces-heading">
614
+ <thead>
615
+ <tr class="border-b border-border bg-muted/50">
616
+ <th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
617
+ Time
618
+ </th>
619
+ <th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
620
+ Trace ID
621
+ </th>
622
+ <th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
623
+ Operation
624
+ </th>
625
+ <th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
626
+ Duration
627
+ </th>
628
+ <th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
629
+ Status
630
+ </th>
631
+ </tr>
632
+ </thead>
633
+ <tbody>
634
+ @for (span of s.recentSpans; track span.spanId || $index) {
635
+ <tr class="border-b border-border last:border-0 hover:bg-muted/30 transition-colors">
636
+ <td class="px-4 py-3 text-muted-foreground whitespace-nowrap">
637
+ {{ formatTime(span.timestamp) }}
638
+ </td>
639
+ <td class="px-4 py-3 font-mono text-xs max-w-32 truncate">
640
+ <span [attr.aria-label]="'Trace ID: ' + span.traceId" [title]="span.traceId">
641
+ {{ truncateId(span.traceId) }}
642
+ </span>
643
+ </td>
644
+ <td class="px-4 py-3 font-medium">{{ span.name }}</td>
645
+ <td class="px-4 py-3 text-muted-foreground">{{ span.durationMs }}ms</td>
646
+ <td class="px-4 py-3">
647
+ <mcms-badge [variant]="spanStatusVariant(span)">{{ span.status }}</mcms-badge>
648
+ </td>
649
+ </tr>
650
+ }
651
+ </tbody>
652
+ </table>
653
+ </div>
654
+ </div>
655
+ </section>
656
+ }
657
+ }
658
+
659
+ <!-- Metrics History -->
660
+ <section class="mb-10" aria-labelledby="metrics-history-heading">
661
+ <div class="flex items-center justify-between mb-4">
662
+ <h2 id="metrics-history-heading" class="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
663
+ Metrics History
664
+ </h2>
665
+ <div class="flex items-center gap-2">
666
+ @for (range of historyRanges; track range.key) {
667
+ <button
668
+ class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors cursor-pointer
669
+ focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
670
+ [class]="selectedRange() === range.key
671
+ ? 'bg-primary text-primary-foreground'
672
+ : 'border border-border bg-background text-foreground hover:bg-muted'"
673
+ (click)="selectRange(range.key)"
674
+ >
675
+ {{ range.label }}
676
+ </button>
677
+ }
678
+ </div>
679
+ </div>
680
+
681
+ <div class="flex items-center gap-3 mb-4">
682
+ <button
683
+ class="inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-md
684
+ border border-border bg-background text-foreground hover:bg-muted
685
+ transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed
686
+ focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
687
+ (click)="exportCsv()"
688
+ [disabled]="otel.exporting()"
689
+ aria-label="Export metrics history as CSV"
690
+ >
691
+ <ng-icon name="heroArrowDownTray" size="14" aria-hidden="true" />
692
+ {{ otel.exporting() ? 'Exporting...' : 'Export CSV' }}
693
+ </button>
694
+ <button
695
+ class="inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-md
696
+ transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed
697
+ focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
698
+ [class]="confirmingPurge()
699
+ ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
700
+ : 'border border-destructive/50 text-destructive hover:bg-destructive/10'"
701
+ (click)="confirmPurge()"
702
+ [disabled]="otel.purging()"
703
+ aria-label="Clear all metrics history"
704
+ >
705
+ <ng-icon name="heroTrash" size="14" aria-hidden="true" />
706
+ {{ otel.purging() ? 'Clearing...' : confirmingPurge() ? 'Confirm Clear' : 'Clear History' }}
707
+ </button>
708
+ @if (otel.historyTotal() > 0) {
709
+ <span class="text-xs text-muted-foreground">
710
+ {{ otel.historyTotal() }} snapshots stored
711
+ </span>
712
+ }
713
+ </div>
714
+
715
+ @if (otel.historyLoading()) {
716
+ <div class="border border-border rounded-lg p-8" aria-busy="true">
717
+ <div class="flex items-center justify-center gap-3 text-muted-foreground">
718
+ <mcms-skeleton class="h-4 w-48" />
719
+ </div>
720
+ </div>
721
+ } @else if (otel.history().length > 0) {
722
+ <div class="border border-border rounded-lg overflow-hidden">
723
+ <div class="overflow-x-auto">
724
+ <table class="w-full text-sm" aria-labelledby="metrics-history-heading">
725
+ <thead>
726
+ <tr class="border-b border-border bg-muted/50">
727
+ <th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
728
+ Timestamp
729
+ </th>
730
+ <th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
731
+ Requests
732
+ </th>
733
+ <th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
734
+ Errors
735
+ </th>
736
+ <th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
737
+ Avg Duration
738
+ </th>
739
+ <th scope="col" class="px-4 py-3 text-left font-medium text-muted-foreground">
740
+ Memory
741
+ </th>
742
+ </tr>
743
+ </thead>
744
+ <tbody>
745
+ @for (snap of otel.history(); track snap.id || $index) {
746
+ <tr class="border-b border-border last:border-0 hover:bg-muted/30 transition-colors">
747
+ <td class="px-4 py-3 text-muted-foreground whitespace-nowrap">
748
+ {{ formatSnapshotTime(snap.createdAt) }}
749
+ </td>
750
+ <td class="px-4 py-3 font-medium">{{ snap.totalRequests }}</td>
751
+ <td class="px-4 py-3">
752
+ <mcms-badge [variant]="snap.errorCount > 0 ? 'destructive' : 'secondary'">
753
+ {{ snap.errorCount }}
754
+ </mcms-badge>
755
+ </td>
756
+ <td class="px-4 py-3 text-muted-foreground">{{ snap.avgDurationMs }}ms</td>
757
+ <td class="px-4 py-3 text-muted-foreground">{{ snap.memoryUsageMb }} MB</td>
758
+ </tr>
759
+ }
760
+ </tbody>
761
+ </table>
762
+ </div>
763
+ </div>
764
+ } @else {
765
+ <div class="border border-border rounded-lg p-8 text-center text-muted-foreground text-sm">
766
+ No history snapshots found for this time range.
767
+ </div>
768
+ }
769
+ </section>
770
+ `
771
+ })
772
+ ], OtelDashboardPage);
773
+ }
774
+ });
775
+
776
+ // libs/plugins/otel/src/lib/otel-admin-routes.ts
777
+ var otelAdminRoutes = [
778
+ {
779
+ path: "observability",
780
+ label: "Observability",
781
+ icon: "heroSignal",
782
+ loadComponent: () => Promise.resolve().then(() => (init_otel_dashboard_page(), otel_dashboard_page_exports)).then((m) => m.OtelDashboardPage),
783
+ group: "Plugins"
784
+ }
785
+ ];
786
+ export {
787
+ otelAdminRoutes
788
+ };