xiaodcs-copilot-api 2.1.0-recovery.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.
@@ -0,0 +1,2113 @@
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>Copilot API Usage Dashboard</title>
7
+
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+
10
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
11
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
+ <link
13
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
14
+ rel="stylesheet"
15
+ />
16
+
17
+ <script src="https://unpkg.com/lucide@0.378.0/dist/umd/lucide.min.js"></script>
18
+
19
+ <style>
20
+ :root {
21
+ --color-red: #dc2626;
22
+ --color-green: #16a34a;
23
+ --color-yellow: #d97706;
24
+ --color-blue: #2563eb;
25
+ --color-purple: #7c3aed;
26
+ --color-aqua: #0891b2;
27
+ --color-orange: #ea580c;
28
+ --color-gray: #64748b;
29
+
30
+ --color-red-accent: #ef4444;
31
+ --color-green-accent: #22c55e;
32
+ --color-yellow-accent: #f59e0b;
33
+ --color-blue-accent: #1d4ed8;
34
+ --color-purple-accent: #8b5cf6;
35
+ --color-aqua-accent: #0ea5e9;
36
+ --color-orange-accent: #f97316;
37
+ --color-gray-accent: #94a3b8;
38
+
39
+ --color-bg-darkest: #0f172a;
40
+ --color-bg: #ffffff;
41
+ --color-bg-light-1: #f8fafc;
42
+ --color-bg-light-2: #e2e8f0;
43
+ --color-bg-light-3: #cbd5e1;
44
+ --color-bg-light-4: #94a3b8;
45
+ --color-bg-soft: #f1f5f9;
46
+
47
+ --color-fg-darker: #64748b;
48
+ --color-fg-dark: #475569;
49
+ --color-fg-medium: #334155;
50
+ --color-fg-light: #1e293b;
51
+ --color-fg-lightest: #0f172a;
52
+ }
53
+
54
+ * {
55
+ box-sizing: border-box;
56
+ }
57
+
58
+ body {
59
+ font-family: "Inter", sans-serif;
60
+ background-color: #eef2f7;
61
+ color: var(--color-fg-light);
62
+ }
63
+
64
+ .app-shell {
65
+ min-height: 100vh;
66
+ background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
67
+ }
68
+
69
+ .main-shell {
70
+ min-width: 0;
71
+ padding: 16px 24px 24px;
72
+ }
73
+
74
+ .topbar {
75
+ margin: 0 auto 10px;
76
+ max-width: 1720px;
77
+ }
78
+
79
+ .topbar-row {
80
+ display: flex;
81
+ align-items: flex-start;
82
+ justify-content: space-between;
83
+ gap: 14px;
84
+ margin-bottom: 10px;
85
+ }
86
+
87
+ .page-title {
88
+ font-size: clamp(28px, 3vw, 38px);
89
+ font-weight: 750;
90
+ line-height: 1.05;
91
+ color: var(--color-fg-lightest);
92
+ }
93
+
94
+ .page-subtitle {
95
+ margin-top: 4px;
96
+ color: var(--color-gray);
97
+ font-size: 14px;
98
+ }
99
+
100
+ .control-surface {
101
+ display: grid;
102
+ grid-template-columns:
103
+ minmax(320px, 1fr)
104
+ minmax(220px, 320px)
105
+ 150px
106
+ 132px;
107
+ gap: 12px;
108
+ align-items: end;
109
+ }
110
+
111
+ .field-label {
112
+ display: block;
113
+ margin-bottom: 6px;
114
+ color: var(--color-fg-medium);
115
+ font-size: 12px;
116
+ font-weight: 700;
117
+ }
118
+
119
+ .input-control,
120
+ .select-control {
121
+ width: 100%;
122
+ height: 42px;
123
+ border: 1px solid var(--color-bg-light-3);
124
+ border-radius: 8px;
125
+ background: rgba(255, 255, 255, 0.88);
126
+ color: var(--color-fg-lightest);
127
+ font-size: 14px;
128
+ outline: none;
129
+ transition:
130
+ border-color 0.16s ease,
131
+ box-shadow 0.16s ease,
132
+ background-color 0.16s ease;
133
+ }
134
+
135
+ .input-control {
136
+ padding: 0 12px;
137
+ }
138
+
139
+ .select-control {
140
+ padding: 0 34px 0 12px;
141
+ }
142
+
143
+ .input-control::placeholder {
144
+ color: #94a3b8;
145
+ }
146
+
147
+ .primary-action {
148
+ height: 42px;
149
+ display: inline-flex;
150
+ align-items: center;
151
+ justify-content: center;
152
+ gap: 8px;
153
+ border: 1px solid transparent;
154
+ border-radius: 8px;
155
+ background: var(--color-blue);
156
+ color: #ffffff;
157
+ font-size: 14px;
158
+ font-weight: 700;
159
+ transition:
160
+ background-color 0.16s ease,
161
+ transform 0.16s ease;
162
+ }
163
+
164
+ .primary-action:hover {
165
+ background: var(--color-blue-accent);
166
+ }
167
+
168
+ .content-area {
169
+ max-width: 1720px;
170
+ margin: 0 auto;
171
+ }
172
+
173
+ .dashboard-section {
174
+ margin-bottom: 10px;
175
+ }
176
+
177
+ .section-heading {
178
+ display: flex;
179
+ align-items: center;
180
+ gap: 6px;
181
+ margin-bottom: 8px;
182
+ color: var(--color-fg-lightest);
183
+ font-size: 16px;
184
+ font-weight: 750;
185
+ }
186
+
187
+ .section-subtitle {
188
+ margin-top: -4px;
189
+ margin-bottom: 8px;
190
+ color: var(--color-gray);
191
+ font-size: 12px;
192
+ }
193
+
194
+ .panel {
195
+ border: 1px solid var(--color-bg-light-2);
196
+ border-radius: 8px;
197
+ background: rgba(255, 255, 255, 0.94);
198
+ box-shadow: 0 12px 28px rgba(15, 23, 42, 0.05);
199
+ }
200
+
201
+ .panel-header {
202
+ display: flex;
203
+ align-items: center;
204
+ justify-content: space-between;
205
+ gap: 12px;
206
+ min-height: 44px;
207
+ padding: 10px 14px;
208
+ border-bottom: 1px solid var(--color-bg-light-2);
209
+ }
210
+
211
+ .panel-title {
212
+ color: var(--color-fg-lightest);
213
+ font-size: 14px;
214
+ font-weight: 750;
215
+ }
216
+
217
+ .panel-meta {
218
+ margin-top: 2px;
219
+ color: var(--color-gray);
220
+ font-size: 12px;
221
+ }
222
+
223
+ .metric-grid {
224
+ display: grid;
225
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
226
+ gap: 10px;
227
+ }
228
+
229
+ .metric-grid-tight {
230
+ grid-template-columns: repeat(7, minmax(0, 1fr));
231
+ }
232
+
233
+ .metric-card {
234
+ min-height: 88px;
235
+ padding: 12px;
236
+ border: 1px solid var(--color-bg-light-2);
237
+ border-radius: 8px;
238
+ background: rgba(255, 255, 255, 0.96);
239
+ box-shadow: 0 10px 22px rgba(15, 23, 42, 0.04);
240
+ }
241
+
242
+ .metric-label {
243
+ display: flex;
244
+ align-items: center;
245
+ gap: 8px;
246
+ color: var(--color-fg-medium);
247
+ font-size: 13px;
248
+ font-weight: 650;
249
+ }
250
+
251
+ .metric-dot {
252
+ width: 10px;
253
+ height: 10px;
254
+ border-radius: 999px;
255
+ }
256
+
257
+ .metric-value {
258
+ margin-top: 10px;
259
+ color: var(--color-fg-lightest);
260
+ font-size: clamp(20px, 2vw, 28px);
261
+ font-weight: 800;
262
+ line-height: 1.1;
263
+ }
264
+
265
+ .metric-note {
266
+ margin-top: 4px;
267
+ color: var(--color-gray);
268
+ font-size: 12px;
269
+ }
270
+
271
+ .progress-bar-bg {
272
+ background-color: var(--color-bg-soft);
273
+ border-radius: 999px;
274
+ overflow: hidden;
275
+ }
276
+
277
+ .progress-bar-fg {
278
+ border-radius: 999px;
279
+ transition: width 0.5s ease-in-out;
280
+ }
281
+
282
+ .code-block::-webkit-scrollbar {
283
+ width: 8px;
284
+ height: 8px;
285
+ }
286
+
287
+ .code-block::-webkit-scrollbar-track {
288
+ background: var(--color-bg-light-1);
289
+ }
290
+
291
+ .code-block::-webkit-scrollbar-thumb {
292
+ background: var(--color-bg-light-3);
293
+ border-radius: 999px;
294
+ }
295
+
296
+ .code-block::-webkit-scrollbar-thumb:hover {
297
+ background: var(--color-bg-light-4);
298
+ }
299
+
300
+ .input-focus:focus {
301
+ border-color: var(--color-blue);
302
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14);
303
+ }
304
+
305
+ button:disabled,
306
+ select:disabled {
307
+ cursor: not-allowed;
308
+ opacity: 0.55;
309
+ }
310
+
311
+ .data-table {
312
+ width: 100%;
313
+ border-collapse: separate;
314
+ border-spacing: 0;
315
+ }
316
+
317
+ .data-table thead {
318
+ background: var(--color-bg-light-1);
319
+ color: var(--color-fg-medium);
320
+ }
321
+
322
+ .data-table th {
323
+ font-size: 12px;
324
+ font-weight: 750;
325
+ }
326
+
327
+ .data-table td {
328
+ font-size: 12px;
329
+ }
330
+
331
+ .data-table th,
332
+ .data-table td {
333
+ border-bottom: 1px solid var(--color-bg-light-2);
334
+ }
335
+
336
+ .pill-button {
337
+ border: 1px solid var(--color-bg-light-2);
338
+ border-radius: 7px;
339
+ background: var(--color-bg);
340
+ color: var(--color-fg-medium);
341
+ }
342
+
343
+ .quick-state-grid {
344
+ display: grid;
345
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
346
+ gap: 10px;
347
+ }
348
+
349
+ @media (max-width: 1280px) {
350
+ .metric-grid-tight {
351
+ grid-template-columns: repeat(4, minmax(0, 1fr));
352
+ }
353
+ }
354
+
355
+ @media (max-width: 1100px) {
356
+ .control-surface {
357
+ grid-template-columns: minmax(0, 1fr) 170px 132px;
358
+ }
359
+
360
+ .endpoint-field {
361
+ grid-column: 1 / -1;
362
+ }
363
+ }
364
+
365
+ @media (max-width: 640px) {
366
+ .main-shell {
367
+ padding: 12px 14px 18px;
368
+ }
369
+
370
+ .topbar-row {
371
+ display: block;
372
+ }
373
+
374
+ .control-surface {
375
+ grid-template-columns: 1fr;
376
+ }
377
+
378
+ .endpoint-field {
379
+ grid-column: auto;
380
+ }
381
+
382
+ .metric-grid-tight {
383
+ grid-template-columns: repeat(2, minmax(0, 1fr));
384
+ }
385
+ }
386
+ </style>
387
+ </head>
388
+ <body class="antialiased">
389
+ <div id="app" class="app-shell">
390
+ <div class="main-shell">
391
+ <header class="topbar">
392
+ <div class="topbar-row">
393
+ <div>
394
+ <h1 class="page-title">Token Usage</h1>
395
+ <p class="page-subtitle">Copilot quota and token billing dashboard</p>
396
+ </div>
397
+ </div>
398
+
399
+ <form id="endpoint-form" class="control-surface">
400
+ <div class="endpoint-field">
401
+ <label for="endpoint-url" class="field-label">Usage Endpoint URL</label>
402
+ <input
403
+ type="text"
404
+ id="endpoint-url"
405
+ class="input-control input-focus"
406
+ placeholder="http://localhost:4141/usage"
407
+ />
408
+ </div>
409
+ <div>
410
+ <label for="x-api-key" class="field-label">x-api-key</label>
411
+ <input
412
+ type="password"
413
+ id="x-api-key"
414
+ class="input-control input-focus"
415
+ placeholder="Optional when auth is enabled"
416
+ autocomplete="off"
417
+ spellcheck="false"
418
+ />
419
+ </div>
420
+ <div>
421
+ <label for="token-usage-period" class="field-label">Period</label>
422
+ <select
423
+ id="token-usage-period"
424
+ class="select-control input-focus"
425
+ >
426
+ <option value="day">Day</option>
427
+ <option value="week">Week</option>
428
+ <option value="month">Month</option>
429
+ </select>
430
+ </div>
431
+ <button id="fetch-button" type="submit" class="primary-action">
432
+ <i data-lucide="refresh-cw" class="h-4 w-4"></i>
433
+ <span>Refresh</span>
434
+ </button>
435
+ </form>
436
+ </header>
437
+
438
+ <main id="content-area" class="content-area"></main>
439
+ </div>
440
+ </div>
441
+
442
+ <script>
443
+ document.addEventListener("DOMContentLoaded", () => {
444
+ const endpointForm = document.getElementById("endpoint-form");
445
+ const endpointUrlInput = document.getElementById("endpoint-url");
446
+ const apiKeyInput = document.getElementById("x-api-key");
447
+ const tokenUsagePeriodSelect = document.getElementById(
448
+ "token-usage-period"
449
+ );
450
+ const contentArea = document.getElementById("content-area");
451
+ const fetchButton = document.getElementById("fetch-button");
452
+ const fetchButtonLabel = fetchButton.querySelector("span");
453
+
454
+ // Apply hover effect for fetch button via JS
455
+ fetchButton.addEventListener("mouseenter", () => {
456
+ fetchButton.style.backgroundColor = "var(--color-blue-accent)";
457
+ });
458
+ fetchButton.addEventListener("mouseleave", () => {
459
+ fetchButton.style.backgroundColor = "var(--color-blue)";
460
+ });
461
+
462
+ const DEFAULT_ENDPOINT = "http://localhost:4141/usage";
463
+ const DEFAULT_TOKEN_USAGE_PERIOD = "day";
464
+ const DEFAULT_TOKEN_USAGE_EVENTS_PAGE_SIZE = 20;
465
+ const ALL_METRICS_VALUE = "__all__";
466
+ const ALL_MODELS_VALUE = "__all__";
467
+ const API_KEY_STORAGE_KEY = "copilot-api.usage-viewer.x-api-key";
468
+ const VALID_PERIODS = new Set(["day", "week", "month"]);
469
+ const EMPTY_TOKEN_USAGE_TOTALS = {
470
+ cache_creation_input_tokens: 0,
471
+ cache_read_input_tokens: 0,
472
+ costs: [],
473
+ input_tokens: 0,
474
+ output_tokens: 0,
475
+ request_count: 0,
476
+ total_nano_aiu: null,
477
+ total_tokens: 0,
478
+ };
479
+
480
+ // --- State Management ---
481
+ const state = {
482
+ isLoading: false,
483
+ isTokenUsageLoading: false,
484
+ isEventsLoading: false,
485
+ error: null,
486
+ data: null,
487
+ tokenUsageDailySummary: null,
488
+ tokenUsageSummary: null,
489
+ tokenUsageEventsPage: null,
490
+ tokenUsageDailyError: null,
491
+ tokenUsageSummaryError: null,
492
+ tokenUsageEventsError: null,
493
+ tokenUsagePeriod: DEFAULT_TOKEN_USAGE_PERIOD,
494
+ tokenUsageTrendDayIndex: null,
495
+ tokenUsageTrendMetric: ALL_METRICS_VALUE,
496
+ tokenUsageTrendModel: ALL_MODELS_VALUE,
497
+ };
498
+
499
+ // --- Rendering Logic ---
500
+
501
+ /**
502
+ * Safely calls lucide.createIcons() if the library is available.
503
+ */
504
+ function createIcons() {
505
+ if (typeof lucide !== "undefined") {
506
+ lucide.createIcons();
507
+ }
508
+ }
509
+
510
+ function escapeHtml(value) {
511
+ return String(value)
512
+ .replaceAll("&", "&amp;")
513
+ .replaceAll("<", "&lt;")
514
+ .replaceAll(">", "&gt;")
515
+ .replaceAll('"', "&quot;")
516
+ .replaceAll("'", "&#39;");
517
+ }
518
+
519
+ function getErrorMessage(error) {
520
+ if (error instanceof Error) {
521
+ return error.message;
522
+ }
523
+ return typeof error === "string" ? error : "Unknown error.";
524
+ }
525
+
526
+ function isMissingTokenUsageEndpointError(error) {
527
+ return Boolean(
528
+ error
529
+ && typeof error === "object"
530
+ && "status" in error
531
+ && error.status === 404
532
+ );
533
+ }
534
+
535
+ function normalizePeriod(value) {
536
+ return VALID_PERIODS.has(value)
537
+ ? value
538
+ : DEFAULT_TOKEN_USAGE_PERIOD;
539
+ }
540
+
541
+ function getSelectedPeriod() {
542
+ const normalized = normalizePeriod(tokenUsagePeriodSelect.value);
543
+ tokenUsagePeriodSelect.value = normalized;
544
+ state.tokenUsagePeriod = normalized;
545
+ return normalized;
546
+ }
547
+
548
+ function loadStoredApiKey() {
549
+ try {
550
+ return localStorage.getItem(API_KEY_STORAGE_KEY) || "";
551
+ } catch (error) {
552
+ console.warn("Could not read stored API key:", getErrorMessage(error));
553
+ return "";
554
+ }
555
+ }
556
+
557
+ function storeApiKey(value) {
558
+ try {
559
+ const trimmed = value.trim();
560
+ if (trimmed) {
561
+ localStorage.setItem(API_KEY_STORAGE_KEY, trimmed);
562
+ } else {
563
+ localStorage.removeItem(API_KEY_STORAGE_KEY);
564
+ }
565
+ } catch (error) {
566
+ console.warn("Could not store API key:", getErrorMessage(error));
567
+ }
568
+ }
569
+
570
+ function buildRequestHeaders() {
571
+ const headers = {};
572
+ const apiKey = apiKeyInput.value.trim();
573
+ if (apiKey) {
574
+ headers["x-api-key"] = apiKey;
575
+ }
576
+ return headers;
577
+ }
578
+
579
+ function resolveUsageUrl(endpoint) {
580
+ return new URL(endpoint, window.location.href).toString();
581
+ }
582
+
583
+ function deriveEndpointUrl(usageEndpoint, targetPath) {
584
+ const url = new URL(usageEndpoint, window.location.href);
585
+ const normalizedTargetPath = targetPath.replace(/^\/+/, "");
586
+ const normalizedPath = url.pathname.replace(/\/+$/, "");
587
+ const nextPath = normalizedPath.endsWith("/usage")
588
+ ? normalizedPath.replace(/\/usage$/, `/${normalizedTargetPath}`)
589
+ : `${normalizedPath || ""}/${normalizedTargetPath}`;
590
+
591
+ url.pathname = nextPath.startsWith("/") ? nextPath : `/${nextPath}`;
592
+ url.search = "";
593
+ url.hash = "";
594
+ return url.toString();
595
+ }
596
+
597
+ function buildTokenUsageSummaryUrl(usageEndpoint, period) {
598
+ const url = new URL(deriveEndpointUrl(usageEndpoint, "token-usage"));
599
+ url.searchParams.set("period", period);
600
+ return url.toString();
601
+ }
602
+
603
+ function buildTokenUsageDailyUrl(usageEndpoint, period) {
604
+ const url = new URL(
605
+ deriveEndpointUrl(usageEndpoint, "token-usage/daily")
606
+ );
607
+ url.searchParams.set("period", period);
608
+ return url.toString();
609
+ }
610
+
611
+ function buildTokenUsageEventsUrl(usageEndpoint, period, page) {
612
+ const url = new URL(
613
+ deriveEndpointUrl(usageEndpoint, "token-usage/events")
614
+ );
615
+ url.searchParams.set("period", period);
616
+ url.searchParams.set("page", String(page));
617
+ url.searchParams.set(
618
+ "page_size",
619
+ String(DEFAULT_TOKEN_USAGE_EVENTS_PAGE_SIZE)
620
+ );
621
+ return url.toString();
622
+ }
623
+
624
+ async function fetchJson(url) {
625
+ const response = await fetch(url, {
626
+ headers: buildRequestHeaders(),
627
+ });
628
+
629
+ if (!response.ok) {
630
+ let message = response.statusText || "Request failed";
631
+
632
+ try {
633
+ const contentType = response.headers.get("content-type") || "";
634
+ if (contentType.includes("application/json")) {
635
+ const payload = await response.json();
636
+ const apiMessage = payload?.error?.message || payload?.message;
637
+ if (typeof apiMessage === "string" && apiMessage.trim()) {
638
+ message = apiMessage.trim();
639
+ }
640
+ } else {
641
+ const text = await response.text();
642
+ if (text.trim()) {
643
+ message = text.trim();
644
+ }
645
+ }
646
+ } catch (error) {
647
+ console.warn(
648
+ "Could not parse error response:",
649
+ getErrorMessage(error)
650
+ );
651
+ }
652
+
653
+ const error = new Error(
654
+ `Request failed with status ${response.status}: ${message}`
655
+ );
656
+ error.status = response.status;
657
+ throw error;
658
+ }
659
+
660
+ return response.json();
661
+ }
662
+
663
+ function syncUrlState() {
664
+ try {
665
+ const currentUrl = new URL(window.location.href);
666
+ const endpoint = endpointUrlInput.value.trim();
667
+ const period = getSelectedPeriod();
668
+
669
+ if (endpoint) {
670
+ currentUrl.searchParams.set("endpoint", endpoint);
671
+ } else {
672
+ currentUrl.searchParams.delete("endpoint");
673
+ }
674
+
675
+ currentUrl.searchParams.set("period", period);
676
+ window.history.pushState({}, "", currentUrl);
677
+ } catch (error) {
678
+ console.warn("Could not update URL:", getErrorMessage(error));
679
+ }
680
+ }
681
+
682
+ function updateControls() {
683
+ fetchButton.disabled = state.isLoading;
684
+ fetchButtonLabel.textContent = state.isLoading
685
+ ? "Refreshing..."
686
+ : "Refresh";
687
+ tokenUsagePeriodSelect.disabled =
688
+ state.isLoading || state.isTokenUsageLoading;
689
+ }
690
+
691
+ function formatNumber(value) {
692
+ return Number.isFinite(value) ? Number(value).toLocaleString() : "0";
693
+ }
694
+
695
+ function getMetricDisplayText(value) {
696
+ return typeof value === "number" ? formatNumber(value) : value;
697
+ }
698
+
699
+ function resolveMetricValueFontSize(displayTexts) {
700
+ const cards = Array.isArray(displayTexts)
701
+ ? displayTexts
702
+ : [displayTexts];
703
+ const longest = cards.reduce((max, cardText) => {
704
+ const lines = Array.isArray(cardText) ? cardText : [cardText];
705
+ const cardMax = lines.reduce((m, line) => {
706
+ return Math.max(m, String(line ?? "").length);
707
+ }, 0);
708
+ return Math.max(max, cardMax);
709
+ }, 0);
710
+ if (longest <= 7) return "";
711
+ if (longest <= 11) return "clamp(15px, 1.3vw, 18px)";
712
+ if (longest <= 15) return "clamp(13px, 1.1vw, 16px)";
713
+ return "clamp(11px, 1vw, 14px)";
714
+ }
715
+
716
+ function formatCurrencyAmount(currency, amount) {
717
+ const symbols = {
718
+ CNY: "¥",
719
+ USD: "$",
720
+ };
721
+ const normalizedCurrency =
722
+ typeof currency === "string" && currency.trim()
723
+ ? currency.trim().toUpperCase()
724
+ : "";
725
+ const symbol = symbols[normalizedCurrency] || "";
726
+ return symbol
727
+ ? `${symbol}${Number(amount).toFixed(6)}`
728
+ : `${normalizedCurrency} ${Number(amount).toFixed(6)}`;
729
+ }
730
+
731
+ function getCostAmount(cost) {
732
+ if (Number.isFinite(cost?.amount)) {
733
+ return Number(cost.amount);
734
+ }
735
+ if (Number.isFinite(cost?.total_cost_nanos)) {
736
+ return Number(cost.total_cost_nanos) / 1_000_000_000;
737
+ }
738
+ return null;
739
+ }
740
+
741
+ function formatTokenUsageCost(cost) {
742
+ const amount = getCostAmount(cost);
743
+ return amount === null || !cost?.currency
744
+ ? "—"
745
+ : formatCurrencyAmount(cost.currency, amount);
746
+ }
747
+
748
+ function formatTokenUsageCosts(costs) {
749
+ if (!Array.isArray(costs) || costs.length === 0) {
750
+ return ["—"];
751
+ }
752
+ return costs.map((cost) => formatTokenUsageCost(cost));
753
+ }
754
+
755
+ function formatDateTime(value) {
756
+ if (!Number.isFinite(value)) {
757
+ return "N/A";
758
+ }
759
+ return new Date(value).toLocaleString();
760
+ }
761
+
762
+ function formatCellText(value) {
763
+ if (typeof value !== "string") {
764
+ return "—";
765
+ }
766
+
767
+ const trimmed = value.trim();
768
+ return trimmed || "—";
769
+ }
770
+
771
+ function renderTokenUsageRangeText(period, range) {
772
+ const labels = {
773
+ day: "Day window",
774
+ week: "Last 7 days",
775
+ month: "Last 30 days",
776
+ };
777
+
778
+ if (!range) {
779
+ return labels[period] || labels.day;
780
+ }
781
+
782
+ return `${labels[period] || labels.day}: ${formatDateTime(
783
+ range.start_ms
784
+ )} - ${formatDateTime(range.end_ms)}`;
785
+ }
786
+
787
+ /**
788
+ * Renders the entire UI based on the current state.
789
+ */
790
+ function render() {
791
+ updateControls();
792
+
793
+ if (state.isLoading) {
794
+ contentArea.innerHTML = renderSpinner();
795
+ createIcons();
796
+ return;
797
+ }
798
+
799
+ const hasContent = Boolean(
800
+ state.error
801
+ || state.data
802
+ || state.isTokenUsageLoading
803
+ || state.isEventsLoading
804
+ || state.tokenUsageDailySummary
805
+ || state.tokenUsageSummary
806
+ || state.tokenUsageEventsPage
807
+ || state.tokenUsageDailyError
808
+ || state.tokenUsageSummaryError
809
+ || state.tokenUsageEventsError
810
+ );
811
+
812
+ if (!hasContent) {
813
+ contentArea.innerHTML = renderWelcomeMessage();
814
+ } else if (state.error && !state.data) {
815
+ contentArea.innerHTML = `
816
+ ${renderTokenUsageSection()}
817
+ ${renderUsageErrorSection()}
818
+ `;
819
+ } else if (state.data) {
820
+ contentArea.innerHTML = `
821
+ ${renderTokenUsageSection()}
822
+ ${renderUsageQuotas(state.data.quota_snapshots)}
823
+ ${renderDetailedData(state.data)}
824
+ `;
825
+ } else {
826
+ contentArea.innerHTML = renderTokenUsageSection();
827
+ }
828
+
829
+ // Replace placeholder icons with actual Lucide icons
830
+ createIcons();
831
+ }
832
+
833
+ /**
834
+ * Renders the "Usage Quotas" section with progress bars.
835
+ * @param {object} snapshots - The quota_snapshots object from the API response.
836
+ * @returns {string} HTML string for the usage quotas section.
837
+ */
838
+ function renderUsageQuotas(snapshots) {
839
+ if (!snapshots) return "";
840
+
841
+ const quotaCards = Object.entries(snapshots)
842
+ .map(([key, value]) => {
843
+ return renderQuotaCard(key, value);
844
+ })
845
+ .join("");
846
+
847
+ return `
848
+ <section id="usage-quotas" class="dashboard-section">
849
+ <h2 class="section-heading">
850
+ <i data-lucide="gauge" class="h-5 w-5" style="color: var(--color-blue);"></i>
851
+ <span>Copilot Quotas</span>
852
+ </h2>
853
+ <div class="metric-grid">
854
+ ${quotaCards}
855
+ </div>
856
+ </section>
857
+ `;
858
+ }
859
+
860
+ function renderUsageErrorSection() {
861
+ if (!state.error || state.data) {
862
+ return "";
863
+ }
864
+
865
+ return `
866
+ <section id="usage-quotas" class="dashboard-section">
867
+ <h2 class="section-heading">
868
+ <i data-lucide="gauge" class="h-5 w-5" style="color: var(--color-blue);"></i>
869
+ <span>Copilot Quotas</span>
870
+ </h2>
871
+ ${renderError(state.error, "Usage request failed")}
872
+ </section>
873
+ `;
874
+ }
875
+
876
+ /**
877
+ * Renders a single quota card.
878
+ * @param {string} title - The name of the quota (e.g., 'chat').
879
+ * @param {object} details - The details object for the quota.
880
+ * @returns {string} HTML string for a single card.
881
+ */
882
+ function renderQuotaCard(title, details) {
883
+ const { entitlement, remaining, percent_remaining, unlimited } =
884
+ details;
885
+
886
+ const percentUsed = unlimited ? 0 : 100 - percent_remaining;
887
+ const used = unlimited
888
+ ? "N/A"
889
+ : (entitlement - remaining).toLocaleString();
890
+
891
+ let progressBarColor = "var(--color-green)";
892
+ if (percentUsed > 75) progressBarColor = "var(--color-yellow)";
893
+ if (percentUsed > 90) progressBarColor = "var(--color-red)";
894
+ if (unlimited) progressBarColor = "var(--color-blue)";
895
+
896
+ return `
897
+ <div class="metric-card">
898
+ <div class="flex items-center justify-between gap-3">
899
+ <div class="metric-label capitalize">
900
+ <span class="metric-dot" style="background-color: ${progressBarColor};"></span>
901
+ <span>${escapeHtml(title.replace(/_/g, " "))}</span>
902
+ </div>
903
+ ${
904
+ unlimited
905
+ ? `<span class="rounded-md px-2 py-1 text-xs font-semibold" style="background-color: rgba(37, 99, 235, 0.1); color: var(--color-blue);">Unlimited</span>`
906
+ : `<span class="text-xs font-semibold" style="color: var(--color-gray);">${percentUsed.toFixed(1)}% used</span>`
907
+ }
908
+ </div>
909
+ <div class="mt-5">
910
+ <div class="w-full progress-bar-bg h-2">
911
+ <div class="progress-bar-fg h-2" style="width: ${unlimited ? 100 : percentUsed}%; background-color: ${progressBarColor};"></div>
912
+ </div>
913
+ </div>
914
+ <div class="mt-2 flex items-center justify-between gap-4 text-xs font-medium" style="color: var(--color-gray);">
915
+ <span>${used} / ${unlimited ? "∞" : entitlement.toLocaleString()}</span>
916
+ <span>${unlimited ? "∞" : remaining.toLocaleString()} remaining</span>
917
+ </div>
918
+ </div>
919
+ `;
920
+ }
921
+
922
+ /**
923
+ * Recursively builds a formatted HTML list from a JSON object.
924
+ * @param {object} obj - The object to format.
925
+ * @returns {string} HTML string for the formatted list.
926
+ */
927
+ function formatObject(obj) {
928
+ if (obj === null || typeof obj !== "object") {
929
+ return `<span style="color: var(--color-green);">${escapeHtml(JSON.stringify(obj))}</span>`;
930
+ }
931
+
932
+ return (
933
+ '<div class="pl-4">' +
934
+ Object.entries(obj)
935
+ .map(([key, value]) => {
936
+ const formattedKey = escapeHtml(key.replace(/_/g, " "));
937
+ let displayValue;
938
+
939
+ if (Array.isArray(value)) {
940
+ displayValue =
941
+ value.length > 0
942
+ ? `<span style='color: var(--color-gray)'>[...${value.length} items]</span>`
943
+ : `<span style='color: var(--color-gray)'>[]</span>`;
944
+ } else if (typeof value === "object" && value !== null) {
945
+ displayValue = formatObject(value);
946
+ } else if (typeof value === "boolean") {
947
+ displayValue = `<span class="font-semibold" style="color: ${value ? "var(--color-green)" : "var(--color-red)"}">${escapeHtml(String(value))}</span>`;
948
+ } else {
949
+ displayValue = `<span style="color: var(--color-blue);">${escapeHtml(JSON.stringify(value))}</span>`;
950
+ }
951
+
952
+ return `<div class="mt-1">
953
+ <span class="capitalize font-semibold" style="color: var(--color-fg-lightest);">${formattedKey}:</span>
954
+ ${typeof value === "object" && value !== null && !Array.isArray(value) ? displayValue : ` ${displayValue}`}
955
+ </div>`;
956
+ })
957
+ .join("") +
958
+ "</div>"
959
+ );
960
+ }
961
+
962
+ /**
963
+ * Renders the section with the full, formatted API response.
964
+ * @param {object} data - The full API response data.
965
+ * @returns {string} HTML string for the full data section.
966
+ */
967
+ function renderDetailedData(data) {
968
+ if (!data || Object.keys(data).length === 0) {
969
+ return "";
970
+ }
971
+
972
+ const formattedDetails = formatObject(data);
973
+ return `
974
+ <section id="detailed-data" class="dashboard-section">
975
+ <h2 class="section-heading">
976
+ <i data-lucide="file-text" class="h-5 w-5" style="color: var(--color-blue);"></i>
977
+ <span>Copilot Usage API Response</span>
978
+ </h2>
979
+ <div class="panel p-4 relative font-mono text-xs code-block overflow-auto" style="max-height: 420px;">
980
+ ${formattedDetails}
981
+ </div>
982
+ </section>
983
+ `;
984
+ }
985
+
986
+ function renderTokenUsageSection() {
987
+ const hasTokenUsageContent = Boolean(
988
+ state.isTokenUsageLoading
989
+ || state.isEventsLoading
990
+ || state.tokenUsageDailySummary
991
+ || state.tokenUsageSummary
992
+ || state.tokenUsageEventsPage
993
+ || state.tokenUsageDailyError
994
+ || state.tokenUsageSummaryError
995
+ || state.tokenUsageEventsError
996
+ );
997
+
998
+ if (!hasTokenUsageContent) {
999
+ return "";
1000
+ }
1001
+
1002
+ const summary = state.tokenUsageSummary;
1003
+ const dailySummary = state.tokenUsageDailySummary;
1004
+ const eventsPage = state.tokenUsageEventsPage;
1005
+ const totals = summary?.totals || EMPTY_TOKEN_USAGE_TOTALS;
1006
+ const activeRange =
1007
+ summary?.range || dailySummary?.range || eventsPage?.range || null;
1008
+ const totalPages = eventsPage ? Math.max(eventsPage.total_pages, 1) : 1;
1009
+ const eventsMeta = eventsPage
1010
+ ? `Page ${eventsPage.page} / ${totalPages} - ${formatNumber(
1011
+ eventsPage.total
1012
+ )} events`
1013
+ : "No detail page loaded yet.";
1014
+
1015
+ return `
1016
+ <section id="token-usage" class="dashboard-section">
1017
+ <div class="mb-2 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
1018
+ <div>
1019
+ <h2 class="section-heading mb-1">
1020
+ <i data-lucide="database" class="h-5 w-5" style="color: var(--color-blue);"></i>
1021
+ <span>Token Usage</span>
1022
+ </h2>
1023
+ <p class="section-subtitle mb-0">${escapeHtml(
1024
+ renderTokenUsageRangeText(state.tokenUsagePeriod, activeRange)
1025
+ )}</p>
1026
+ </div>
1027
+ <div class="flex flex-wrap items-center gap-2 text-xs font-medium" style="color: var(--color-gray);">
1028
+ ${
1029
+ state.isTokenUsageLoading
1030
+ ? `<span class="rounded-md px-2 py-1" style="background-color: rgba(37, 99, 235, 0.08); color: var(--color-blue);">Refreshing summary...</span>`
1031
+ : ""
1032
+ }
1033
+ ${
1034
+ state.isEventsLoading
1035
+ ? `<span class="rounded-md px-2 py-1" style="background-color: rgba(37, 99, 235, 0.08); color: var(--color-blue);">Refreshing details...</span>`
1036
+ : ""
1037
+ }
1038
+ </div>
1039
+ </div>
1040
+
1041
+ ${
1042
+ state.tokenUsageSummaryError
1043
+ ? renderError(
1044
+ state.tokenUsageSummaryError,
1045
+ "Token usage summary failed"
1046
+ )
1047
+ : ""
1048
+ }
1049
+
1050
+ <div class="metric-grid metric-grid-tight ${
1051
+ state.isTokenUsageLoading ? "opacity-60" : ""
1052
+ }">
1053
+ ${
1054
+ (() => {
1055
+ const metricFontSize = resolveMetricValueFontSize([
1056
+ getMetricDisplayText(totals.total_tokens),
1057
+ getMetricDisplayText(totals.input_tokens),
1058
+ getMetricDisplayText(totals.output_tokens),
1059
+ getMetricDisplayText(totals.cache_read_input_tokens),
1060
+ getMetricDisplayText(totals.cache_creation_input_tokens),
1061
+ getMetricDisplayText(totals.request_count),
1062
+ formatTokenUsageCosts(totals.costs),
1063
+ ]);
1064
+ return [
1065
+ renderTokenUsageMetric(
1066
+ "Total",
1067
+ totals.total_tokens,
1068
+ "var(--color-fg-lightest)",
1069
+ metricFontSize
1070
+ ),
1071
+ renderTokenUsageMetric(
1072
+ "Input",
1073
+ totals.input_tokens,
1074
+ "var(--color-blue-accent)",
1075
+ metricFontSize
1076
+ ),
1077
+ renderTokenUsageMetric(
1078
+ "Output",
1079
+ totals.output_tokens,
1080
+ "var(--color-green-accent)",
1081
+ metricFontSize
1082
+ ),
1083
+ renderTokenUsageMetric(
1084
+ "Cache Read",
1085
+ totals.cache_read_input_tokens,
1086
+ "var(--color-aqua-accent)",
1087
+ metricFontSize
1088
+ ),
1089
+ renderTokenUsageMetric(
1090
+ "Cache Write",
1091
+ totals.cache_creation_input_tokens,
1092
+ "var(--color-yellow-accent)",
1093
+ metricFontSize
1094
+ ),
1095
+ renderTokenUsageMetric(
1096
+ "Requests",
1097
+ totals.request_count,
1098
+ "var(--color-purple-accent)",
1099
+ metricFontSize
1100
+ ),
1101
+ renderTokenUsageCostMetric(
1102
+ "Cost",
1103
+ formatTokenUsageCosts(totals.costs),
1104
+ "var(--color-yellow-accent)",
1105
+ metricFontSize
1106
+ ),
1107
+ ].join("");
1108
+ })()
1109
+ }
1110
+ </div>
1111
+
1112
+ ${renderTokenUsageDailyTrend(
1113
+ dailySummary,
1114
+ state.tokenUsageDailyError
1115
+ )}
1116
+
1117
+ <div class="mt-3 grid grid-cols-1 gap-3">
1118
+ <div class="panel">
1119
+ <div class="panel-header">
1120
+ <div>
1121
+ <p class="panel-title">Model Breakdown</p>
1122
+ <p class="panel-meta">${summary ? `${formatNumber(summary.byModel.length)} models` : "No summary rows loaded."}</p>
1123
+ </div>
1124
+ </div>
1125
+ ${renderTokenUsageModelBreakdown(summary)}
1126
+ </div>
1127
+
1128
+ <div class="panel">
1129
+ <div class="panel-header flex-col items-start sm:flex-row sm:items-center">
1130
+ <div>
1131
+ <p class="panel-title">Request Events</p>
1132
+ <p class="panel-meta">${escapeHtml(eventsMeta)}</p>
1133
+ </div>
1134
+ <div class="flex items-center gap-2">
1135
+ ${renderPaginationButton(
1136
+ "previous",
1137
+ "Previous",
1138
+ !eventsPage || state.isEventsLoading || eventsPage.page <= 1
1139
+ )}
1140
+ ${renderPaginationButton(
1141
+ "next",
1142
+ "Next",
1143
+ !eventsPage
1144
+ || state.isEventsLoading
1145
+ || eventsPage.page >= totalPages
1146
+ )}
1147
+ </div>
1148
+ </div>
1149
+ ${
1150
+ state.tokenUsageEventsError
1151
+ ? `<div class="p-4">${renderError(
1152
+ state.tokenUsageEventsError,
1153
+ "Token usage details failed"
1154
+ )}</div>`
1155
+ : ""
1156
+ }
1157
+ ${renderTokenUsageEventsTable(eventsPage)}
1158
+ </div>
1159
+ </div>
1160
+ </section>
1161
+ `;
1162
+ }
1163
+
1164
+ function getTokenUsageTrendMetrics() {
1165
+ return [
1166
+ {
1167
+ color: "var(--color-fg-lightest)",
1168
+ key: "total_tokens",
1169
+ label: "Total",
1170
+ },
1171
+ {
1172
+ color: "var(--color-blue-accent)",
1173
+ key: "input_tokens",
1174
+ label: "Input",
1175
+ },
1176
+ {
1177
+ color: "var(--color-green-accent)",
1178
+ key: "output_tokens",
1179
+ label: "Output",
1180
+ },
1181
+ {
1182
+ color: "var(--color-aqua-accent)",
1183
+ key: "cache_read_input_tokens",
1184
+ label: "Cache Read",
1185
+ },
1186
+ {
1187
+ color: "var(--color-yellow-accent)",
1188
+ key: "cache_creation_input_tokens",
1189
+ label: "Cache Write",
1190
+ },
1191
+ ];
1192
+ }
1193
+
1194
+ function getDailyTrendTotals(day, selectedModel) {
1195
+ if (selectedModel === ALL_MODELS_VALUE) {
1196
+ return day.totals || EMPTY_TOKEN_USAGE_TOTALS;
1197
+ }
1198
+
1199
+ return (
1200
+ day.byModel?.find((model) => model.model === selectedModel)
1201
+ || EMPTY_TOKEN_USAGE_TOTALS
1202
+ );
1203
+ }
1204
+
1205
+ function formatChartDate(value) {
1206
+ const parts = String(value).split("-");
1207
+ return parts.length === 3 ? `${parts[1]}/${parts[2]}` : value;
1208
+ }
1209
+
1210
+ function renderTokenUsageDailyTrend(dailySummary, error) {
1211
+ if (state.tokenUsagePeriod === "day") {
1212
+ return "";
1213
+ }
1214
+
1215
+ const models = dailySummary?.byModel || [];
1216
+ const selectedModel = models.some(
1217
+ (model) => model.model === state.tokenUsageTrendModel
1218
+ )
1219
+ ? state.tokenUsageTrendModel
1220
+ : ALL_MODELS_VALUE;
1221
+ const options = [
1222
+ `<option value="${ALL_MODELS_VALUE}">All models</option>`,
1223
+ ...models.map(
1224
+ (model) =>
1225
+ `<option value="${escapeHtml(model.model)}" ${
1226
+ selectedModel === model.model ? "selected" : ""
1227
+ }>${escapeHtml(model.model)}</option>`
1228
+ ),
1229
+ ].join("");
1230
+
1231
+ return `
1232
+ <div class="mt-3 grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_304px] gap-3">
1233
+ <div class="panel">
1234
+ <div class="panel-header">
1235
+ <div>
1236
+ <p class="panel-title">Token Usage Over Time</p>
1237
+ <p class="panel-meta">${escapeHtml(
1238
+ renderTokenUsageRangeText(
1239
+ state.tokenUsagePeriod,
1240
+ dailySummary?.range || null
1241
+ )
1242
+ )}</p>
1243
+ </div>
1244
+ </div>
1245
+ ${
1246
+ error
1247
+ ? `<div class="p-4">${renderError(
1248
+ error,
1249
+ "Token usage daily trend failed"
1250
+ )}</div>`
1251
+ : ""
1252
+ }
1253
+ ${renderTokenUsageTrendChart(dailySummary, selectedModel)}
1254
+ </div>
1255
+
1256
+ <aside class="panel">
1257
+ <div class="panel-header">
1258
+ <div>
1259
+ <p class="panel-title">Filters</p>
1260
+ <p class="panel-meta">Applied to the trend chart</p>
1261
+ </div>
1262
+ </div>
1263
+ <div class="p-4 space-y-4">
1264
+ <div>
1265
+ <label class="field-label">Model</label>
1266
+ <select
1267
+ data-token-usage-trend-model
1268
+ class="select-control input-focus text-sm"
1269
+ >${options}</select>
1270
+ </div>
1271
+ <div>
1272
+ <div class="field-label">Metric</div>
1273
+ <div class="flex flex-wrap gap-2">
1274
+ ${renderTokenUsageTrendMetricButtons()}
1275
+ </div>
1276
+ </div>
1277
+ </div>
1278
+ </aside>
1279
+ </div>
1280
+ `;
1281
+ }
1282
+
1283
+ function renderTokenUsageTrendMetricButtons() {
1284
+ const metrics = getTokenUsageTrendMetrics();
1285
+ return [
1286
+ {
1287
+ color: "var(--color-fg-lightest)",
1288
+ key: ALL_METRICS_VALUE,
1289
+ label: "All",
1290
+ },
1291
+ ...metrics,
1292
+ ]
1293
+ .map((metric) => {
1294
+ const isActive = state.tokenUsageTrendMetric === metric.key;
1295
+ return `
1296
+ <button
1297
+ type="button"
1298
+ data-trend-metric="${metric.key}"
1299
+ class="pill-button inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-semibold"
1300
+ style="background-color: ${isActive ? "rgba(37, 99, 235, 0.1)" : "var(--color-bg)"}; border-color: ${isActive ? "rgba(37, 99, 235, 0.28)" : "var(--color-bg-light-2)"}; color: ${isActive ? "var(--color-blue)" : "var(--color-fg-medium)"};"
1301
+ >
1302
+ <span class="h-2 w-2 rounded-full" style="background-color: ${metric.color};"></span>
1303
+ ${escapeHtml(metric.label)}
1304
+ </button>
1305
+ `;
1306
+ })
1307
+ .join("");
1308
+ }
1309
+
1310
+ function renderTokenUsageTrendChart(dailySummary, selectedModel) {
1311
+ const days = dailySummary?.days || [];
1312
+ const metrics = getTokenUsageTrendMetrics();
1313
+ const visibleMetrics =
1314
+ state.tokenUsageTrendMetric === ALL_METRICS_VALUE
1315
+ ? metrics
1316
+ : metrics.filter(
1317
+ (metric) => metric.key === state.tokenUsageTrendMetric
1318
+ );
1319
+ const hasUsage = days.some(
1320
+ (day) => getDailyTrendTotals(day, selectedModel).request_count > 0
1321
+ );
1322
+ const latestUsageIndex = days.reduce((latest, day, index) => {
1323
+ return getDailyTrendTotals(day, selectedModel).request_count > 0
1324
+ ? index
1325
+ : latest;
1326
+ }, -1);
1327
+ const activeIndex =
1328
+ Number.isInteger(state.tokenUsageTrendDayIndex)
1329
+ && state.tokenUsageTrendDayIndex >= 0
1330
+ && state.tokenUsageTrendDayIndex < days.length
1331
+ ? state.tokenUsageTrendDayIndex
1332
+ : latestUsageIndex >= 0
1333
+ ? latestUsageIndex
1334
+ : Math.max(0, days.length - 1);
1335
+ const activeDay = days[activeIndex];
1336
+ const activeTotals = activeDay
1337
+ ? getDailyTrendTotals(activeDay, selectedModel)
1338
+ : EMPTY_TOKEN_USAGE_TOTALS;
1339
+
1340
+ if (state.isTokenUsageLoading && !dailySummary) {
1341
+ return `<div class="p-4 min-h-[14rem] text-sm animate-pulse" style="color: var(--color-gray);">Loading...</div>`;
1342
+ }
1343
+
1344
+ if (!dailySummary || days.length === 0 || !hasUsage) {
1345
+ return renderEmptyState(
1346
+ "No token usage recorded for the selected period."
1347
+ );
1348
+ }
1349
+
1350
+ const width = 720;
1351
+ const height = 220;
1352
+ const padding = { bottom: 34, left: 58, right: 16, top: 16 };
1353
+ const plotWidth = width - padding.left - padding.right;
1354
+ const plotHeight = height - padding.top - padding.bottom;
1355
+ const allValues = days.flatMap((day) => {
1356
+ const totals = getDailyTrendTotals(day, selectedModel);
1357
+ return visibleMetrics.map((metric) => totals[metric.key] || 0);
1358
+ });
1359
+ const maxValue = Math.max(1, ...allValues);
1360
+ const xForIndex = (index) =>
1361
+ days.length <= 1
1362
+ ? padding.left + plotWidth / 2
1363
+ : padding.left + (plotWidth * index) / (days.length - 1);
1364
+ const hitWidth =
1365
+ days.length <= 1 ? plotWidth : plotWidth / (days.length - 1);
1366
+ const yForValue = (value) =>
1367
+ padding.top
1368
+ + plotHeight
1369
+ - (Math.max(0, value) / maxValue) * plotHeight;
1370
+ const labelEvery = Math.max(1, Math.ceil(days.length / 6));
1371
+ const yTicks = [0, 0.25, 0.5, 0.75, 1].map((ratio) =>
1372
+ Math.round(maxValue * ratio)
1373
+ );
1374
+
1375
+ const grid = yTicks
1376
+ .map((tick) => {
1377
+ const y = yForValue(tick);
1378
+ return `
1379
+ <line x1="${padding.left}" x2="${
1380
+ width - padding.right
1381
+ }" y1="${y}" y2="${y}" stroke="var(--color-bg-light-2)" stroke-width="1" />
1382
+ <text x="${padding.left - 8}" y="${
1383
+ y + 4
1384
+ }" text-anchor="end" font-size="11" fill="var(--color-gray-accent)">${formatNumber(
1385
+ tick
1386
+ )}</text>
1387
+ `;
1388
+ })
1389
+ .join("");
1390
+ const xLabels = days
1391
+ .map((day, index) => {
1392
+ if (index % labelEvery !== 0 && index !== days.length - 1) {
1393
+ return "";
1394
+ }
1395
+ return `<text x="${xForIndex(index)}" y="${
1396
+ height - 10
1397
+ }" text-anchor="middle" font-size="11" fill="var(--color-gray-accent)">${escapeHtml(
1398
+ formatChartDate(day.date)
1399
+ )}</text>`;
1400
+ })
1401
+ .join("");
1402
+ const lines = visibleMetrics
1403
+ .map((metric) => {
1404
+ const points = days
1405
+ .map((day, index) => {
1406
+ const totals = getDailyTrendTotals(day, selectedModel);
1407
+ return `${xForIndex(index)},${yForValue(
1408
+ totals[metric.key] || 0
1409
+ )}`;
1410
+ })
1411
+ .join(" ");
1412
+ const circles = days
1413
+ .map((day, index) => {
1414
+ const totals = getDailyTrendTotals(day, selectedModel);
1415
+ const value = totals[metric.key] || 0;
1416
+ return `<circle cx="${xForIndex(index)}" cy="${yForValue(
1417
+ value
1418
+ )}" r="2.5" fill="${
1419
+ metric.color
1420
+ }" data-trend-day-index="${index}">
1421
+ <title>${escapeHtml(
1422
+ `${formatChartDate(day.date)} ${metric.label}: ${formatNumber(
1423
+ value
1424
+ )}`
1425
+ )}</title>
1426
+ </circle>`;
1427
+ })
1428
+ .join("");
1429
+ return `
1430
+ <polyline fill="none" points="${points}" stroke="${metric.color}" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.25">
1431
+ <title>${escapeHtml(metric.label)}</title>
1432
+ </polyline>
1433
+ ${circles}
1434
+ `;
1435
+ })
1436
+ .join("");
1437
+ const activeMarker = activeDay
1438
+ ? `<line x1="${xForIndex(activeIndex)}" x2="${xForIndex(
1439
+ activeIndex
1440
+ )}" y1="${padding.top}" y2="${
1441
+ padding.top + plotHeight
1442
+ }" stroke="var(--color-bg-light-4)" stroke-dasharray="4 4" stroke-width="1" />`
1443
+ : "";
1444
+ const hitRects = days
1445
+ .map((day, index) => {
1446
+ const x = xForIndex(index);
1447
+ const hitStart = Math.max(padding.left, x - hitWidth / 2);
1448
+ const hitEnd = Math.min(
1449
+ width - padding.right,
1450
+ x + hitWidth / 2
1451
+ );
1452
+ const totals = getDailyTrendTotals(day, selectedModel);
1453
+ const title = [
1454
+ formatChartDate(day.date),
1455
+ ...visibleMetrics.map(
1456
+ (metric) =>
1457
+ `${metric.label}: ${formatNumber(
1458
+ totals[metric.key] || 0
1459
+ )}`
1460
+ ),
1461
+ ].join("\n");
1462
+ return `<rect x="${hitStart}" y="${padding.top}" width="${
1463
+ hitEnd - hitStart
1464
+ }" height="${plotHeight}" fill="transparent" style="cursor: pointer;" data-trend-day-index="${index}">
1465
+ <title>${escapeHtml(title)}</title>
1466
+ </rect>`;
1467
+ })
1468
+ .join("");
1469
+ const legend = [
1470
+ ...metrics.map(
1471
+ (metric) => `
1472
+ <span class="inline-flex items-center gap-1.5 text-xs font-medium" style="color: var(--color-gray);">
1473
+ <span class="h-2 w-2 rounded-full" style="background-color: ${
1474
+ metric.color
1475
+ };"></span>
1476
+ ${escapeHtml(metric.label)}
1477
+ </span>
1478
+ `
1479
+ ),
1480
+ ].join("");
1481
+ const activeValues = activeDay
1482
+ ? `
1483
+ <div class="mt-2 flex flex-wrap gap-3 px-3 py-2 text-xs" style="background-color: var(--color-bg-soft); color: var(--color-fg-dark);">
1484
+ <span class="font-semibold" style="color: var(--color-fg-lightest);">${escapeHtml(
1485
+ formatChartDate(activeDay.date)
1486
+ )}</span>
1487
+ ${visibleMetrics
1488
+ .map(
1489
+ (metric) =>
1490
+ `<span>${escapeHtml(metric.label)}: ${formatNumber(
1491
+ activeTotals[metric.key] || 0
1492
+ )}</span>`
1493
+ )
1494
+ .join("")}
1495
+ </div>
1496
+ `
1497
+ : "";
1498
+
1499
+ return `
1500
+ <div class="p-4 ${state.isTokenUsageLoading ? "opacity-60" : ""}">
1501
+ <svg class="w-full h-56" viewBox="0 0 ${width} ${height}" role="img">
1502
+ ${grid}
1503
+ ${xLabels}
1504
+ ${activeMarker}
1505
+ ${lines}
1506
+ ${hitRects}
1507
+ </svg>
1508
+ <div class="mt-2 flex flex-wrap gap-5">
1509
+ ${legend}
1510
+ </div>
1511
+ ${activeValues}
1512
+ </div>
1513
+ `;
1514
+ }
1515
+
1516
+ function renderTokenUsageValueLines(value) {
1517
+ const lines = Array.isArray(value) ? value : [value];
1518
+ return lines
1519
+ .map((line) => `<span class="block whitespace-nowrap">${escapeHtml(line)}</span>`)
1520
+ .join("");
1521
+ }
1522
+
1523
+ function renderTokenUsageMetric(label, value, accentColor, fontSize) {
1524
+ const displayText = getMetricDisplayText(value);
1525
+ return `
1526
+ <div class="metric-card">
1527
+ <div class="metric-label">
1528
+ <span class="metric-dot" style="background-color: ${accentColor};"></span>
1529
+ <span>${escapeHtml(label)}</span>
1530
+ </div>
1531
+ <div class="metric-value" style="color: var(--color-fg-lightest);${
1532
+ fontSize ? ` font-size: ${fontSize};` : ""
1533
+ }">${renderTokenUsageValueLines(displayText)}</div>
1534
+ <div class="metric-note">Current period</div>
1535
+ </div>
1536
+ `;
1537
+ }
1538
+
1539
+ function renderTokenUsageCostMetric(label, value, accentColor, fontSize) {
1540
+ return `
1541
+ <div class="metric-card">
1542
+ <div class="metric-label">
1543
+ <span class="metric-dot" style="background-color: ${accentColor};"></span>
1544
+ <span>${escapeHtml(label)}</span>
1545
+ </div>
1546
+ <div class="metric-value" style="color: var(--color-fg-lightest);${
1547
+ fontSize ? ` font-size: ${fontSize};` : ""
1548
+ }">${renderTokenUsageValueLines(value)}</div>
1549
+ <div class="metric-note">Estimated total</div>
1550
+ </div>
1551
+ `;
1552
+ }
1553
+
1554
+ function renderTokenUsageModelBreakdown(summary) {
1555
+ if (!summary || summary.byModel.length === 0) {
1556
+ return renderEmptyState(
1557
+ "No token usage recorded for the selected period."
1558
+ );
1559
+ }
1560
+
1561
+ const rows = summary.byModel
1562
+ .map((model) => {
1563
+ return `
1564
+ <tr class="border-b last:border-b-0" style="border-color: var(--color-bg-light-1);">
1565
+ <td class="px-4 py-2 max-w-[280px] truncate" style="color: var(--color-fg-lightest);" title="${escapeHtml(
1566
+ model.model
1567
+ )}">${escapeHtml(model.model)}</td>
1568
+ <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
1569
+ model.request_count
1570
+ )}</td>
1571
+ <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
1572
+ model.input_tokens
1573
+ )}</td>
1574
+ <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
1575
+ model.output_tokens
1576
+ )}</td>
1577
+ <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
1578
+ model.cache_read_input_tokens
1579
+ )}</td>
1580
+ <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
1581
+ model.cache_creation_input_tokens
1582
+ )}</td>
1583
+ <td class="px-4 py-2 text-right font-semibold" style="color: var(--color-yellow-accent);">${formatNumber(
1584
+ model.total_tokens
1585
+ )}</td>
1586
+ <td class="px-4 py-2 text-right font-semibold" style="color: var(--color-yellow-accent);">
1587
+ <span class="inline-flex flex-col items-end leading-5">${renderTokenUsageValueLines(
1588
+ formatTokenUsageCosts(model.costs)
1589
+ )}</span>
1590
+ </td>
1591
+ </tr>
1592
+ `;
1593
+ })
1594
+ .join("");
1595
+
1596
+ return `
1597
+ <div class="overflow-auto ${state.isTokenUsageLoading ? "opacity-60" : ""}">
1598
+ <table class="data-table min-w-[860px] text-left">
1599
+ <thead>
1600
+ <tr>
1601
+ <th class="px-4 py-2 font-semibold">Model</th>
1602
+ <th class="px-4 py-2 text-right font-semibold">Requests</th>
1603
+ <th class="px-4 py-2 text-right font-semibold">Input</th>
1604
+ <th class="px-4 py-2 text-right font-semibold">Output</th>
1605
+ <th class="px-4 py-2 text-right font-semibold">Cache Read</th>
1606
+ <th class="px-4 py-2 text-right font-semibold">Cache Write</th>
1607
+ <th class="px-4 py-2 text-right font-semibold">Total Tokens</th>
1608
+ <th class="px-4 py-2 text-right font-semibold">Total Cost</th>
1609
+ </tr>
1610
+ </thead>
1611
+ <tbody>
1612
+ ${rows}
1613
+ </tbody>
1614
+ </table>
1615
+ </div>
1616
+ `;
1617
+ }
1618
+
1619
+ function renderTokenUsageEventsTable(eventsPage) {
1620
+ if (!eventsPage || eventsPage.items.length === 0) {
1621
+ return renderEmptyState(
1622
+ "No token usage detail rows for the selected period."
1623
+ );
1624
+ }
1625
+
1626
+ const rows = eventsPage.items
1627
+ .map((event) => {
1628
+ const userId = formatCellText(event.user_id);
1629
+ const sessionId = formatCellText(event.session_id);
1630
+ const traceId = formatCellText(event.trace_id);
1631
+
1632
+ return `
1633
+ <tr class="border-b last:border-b-0" style="border-color: var(--color-bg-light-1);">
1634
+ <td class="px-4 py-2 max-w-[170px] truncate whitespace-nowrap" style="color: var(--color-fg-dark);" title="${escapeHtml(
1635
+ event.created_at_utc
1636
+ )}">${escapeHtml(formatDateTime(event.created_at_ms))}</td>
1637
+ <td class="px-4 py-2 max-w-[100px] truncate" style="color: var(--color-fg-lightest);" title="${escapeHtml(
1638
+ userId
1639
+ )}">${escapeHtml(userId)}</td>
1640
+
1641
+ <td class="px-4 py-2 max-w-[140px] truncate" style="color: var(--color-fg-lightest);" title="${escapeHtml(
1642
+ event.model
1643
+ )}">${escapeHtml(event.model)}</td>
1644
+ <td class="px-4 py-2 max-w-[180px] truncate font-mono" style="color: var(--color-fg-dark);" title="${escapeHtml(
1645
+ sessionId
1646
+ )}">${escapeHtml(sessionId)}</td>
1647
+ <td class="px-4 py-2 max-w-[200px] truncate font-mono" style="color: var(--color-fg-dark);" title="${escapeHtml(
1648
+ traceId
1649
+ )}">${escapeHtml(traceId)}</td>
1650
+ <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
1651
+ event.input_tokens
1652
+ )}</td>
1653
+ <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
1654
+ event.output_tokens
1655
+ )}</td>
1656
+ <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
1657
+ event.cache_read_input_tokens
1658
+ )}</td>
1659
+ <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
1660
+ event.cache_creation_input_tokens
1661
+ )}</td>
1662
+ <td class="px-4 py-2 text-right font-semibold" style="color: var(--color-yellow-accent);">${formatNumber(
1663
+ event.total_tokens
1664
+ )}</td>
1665
+ <td class="px-4 py-2 text-right font-semibold" style="color: var(--color-yellow-accent);">
1666
+ <span class="inline-flex flex-col items-end leading-5">${renderTokenUsageValueLines(
1667
+ formatTokenUsageCost(event.cost)
1668
+ )}</span>
1669
+ </td>
1670
+ </tr>
1671
+ `;
1672
+ })
1673
+ .join("");
1674
+
1675
+ return `
1676
+ <div class="overflow-auto ${state.isEventsLoading ? "opacity-60" : ""}">
1677
+ <table class="data-table min-w-[1000px] text-left">
1678
+ <thead>
1679
+ <tr>
1680
+ <th class="px-4 py-2 font-semibold">Time</th>
1681
+ <th class="px-4 py-2 font-semibold">User</th>
1682
+
1683
+ <th class="px-4 py-2 font-semibold">Model</th>
1684
+ <th class="px-4 py-2 font-semibold">Session</th>
1685
+ <th class="px-4 py-2 font-semibold">Trace</th>
1686
+ <th class="px-4 py-2 text-right font-semibold">Input</th>
1687
+ <th class="px-4 py-2 text-right font-semibold">Output</th>
1688
+ <th class="px-4 py-2 text-right font-semibold">Cache Read</th>
1689
+ <th class="px-4 py-2 text-right font-semibold">Cache Write</th>
1690
+ <th class="px-4 py-2 text-right font-semibold">Total Tokens</th>
1691
+ <th class="px-4 py-2 text-right font-semibold">Total Cost</th>
1692
+ </tr>
1693
+ </thead>
1694
+ <tbody>
1695
+ ${rows}
1696
+ </tbody>
1697
+ </table>
1698
+ </div>
1699
+ `;
1700
+ }
1701
+
1702
+ function renderPaginationButton(action, label, disabled) {
1703
+ return `
1704
+ <button
1705
+ type="button"
1706
+ data-page-action="${action}"
1707
+ class="pill-button px-3 py-1.5 text-xs font-semibold"
1708
+ ${disabled ? "disabled" : ""}
1709
+ >
1710
+ ${label}
1711
+ </button>
1712
+ `;
1713
+ }
1714
+
1715
+ function renderEmptyState(message) {
1716
+ return `
1717
+ <div class="px-4 py-8 text-sm" style="color: var(--color-gray);">
1718
+ ${escapeHtml(message)}
1719
+ </div>
1720
+ `;
1721
+ }
1722
+
1723
+ /**
1724
+ * Renders a loading spinner.
1725
+ * @returns {string} HTML string for the spinner.
1726
+ */
1727
+ function renderSpinner() {
1728
+ return `
1729
+ <div class="flex justify-center items-center py-20">
1730
+ <div class="animate-spin h-12 w-12 rounded-full border-4 border-transparent border-t-4" style="border-top-color: var(--color-blue); background-color: rgba(255, 255, 255, 0.65);"></div>
1731
+ </div>`;
1732
+ }
1733
+
1734
+ /**
1735
+ * Renders an error message box.
1736
+ * @param {string} message - The error message to display.
1737
+ * @returns {string} HTML string for the error message.
1738
+ */
1739
+ function renderError(message, title = "An Error Occurred") {
1740
+ return `
1741
+ <div
1742
+ class="p-3 border rounded-lg"
1743
+ style="background-color: rgba(220, 38, 38, 0.08); border-color: rgba(220, 38, 38, 0.22); color: var(--color-red);"
1744
+ role="alert"
1745
+ >
1746
+ <div class="flex items-start">
1747
+ <i data-lucide="alert-triangle" class="h-5 w-5 mr-3 mt-0.5"></i>
1748
+ <div>
1749
+ <p class="font-bold text-sm">${escapeHtml(title)}</p>
1750
+ <p class="text-xs">${escapeHtml(message)}</p>
1751
+ </div>
1752
+ </div>
1753
+ </div>
1754
+ `;
1755
+ }
1756
+
1757
+ /**
1758
+ * Renders a welcome message when the page first loads.
1759
+ * @returns {string} HTML string for the welcome message.
1760
+ */
1761
+ function renderWelcomeMessage() {
1762
+ return `
1763
+ <div class="panel text-center py-16 px-4">
1764
+ <i data-lucide="info" class="mx-auto h-10 w-10" style="color: var(--color-blue);"></i>
1765
+ <h3 class="mt-3 text-lg font-bold" style="color: var(--color-fg-lightest);">Usage data is ready to load</h3>
1766
+ <p class="mt-2 text-sm" style="color: var(--color-gray);">Refresh the configured endpoint to view Copilot quotas, token totals, trends, and request events.</p>
1767
+ </div>
1768
+ `;
1769
+ }
1770
+
1771
+ // --- Data Fetching ---
1772
+
1773
+ /**
1774
+ * Fetches usage data and token usage data from the specified API endpoint.
1775
+ */
1776
+ async function fetchData(page = 1) {
1777
+ const endpoint = endpointUrlInput.value.trim();
1778
+ if (!endpoint) {
1779
+ state.data = null;
1780
+ state.tokenUsageDailySummary = null;
1781
+ state.tokenUsageSummary = null;
1782
+ state.tokenUsageEventsPage = null;
1783
+ state.error = "Endpoint URL cannot be empty.";
1784
+ state.tokenUsageDailyError =
1785
+ "Token usage requires a valid endpoint URL.";
1786
+ state.tokenUsageSummaryError =
1787
+ "Token usage requires a valid endpoint URL.";
1788
+ state.tokenUsageEventsError = null;
1789
+ render();
1790
+ return;
1791
+ }
1792
+
1793
+ const period = getSelectedPeriod();
1794
+ state.isLoading = true;
1795
+ state.error = null;
1796
+ state.tokenUsageDailyError = null;
1797
+ state.tokenUsageSummaryError = null;
1798
+ state.tokenUsageEventsError = null;
1799
+ render();
1800
+
1801
+ try {
1802
+ const usageUrl = resolveUsageUrl(endpoint);
1803
+ const [usageResult, summaryResult, dailyResult, eventsResult] =
1804
+ await Promise.allSettled([
1805
+ fetchJson(usageUrl),
1806
+ fetchJson(buildTokenUsageSummaryUrl(usageUrl, period)),
1807
+ fetchJson(buildTokenUsageDailyUrl(usageUrl, period)),
1808
+ fetchJson(buildTokenUsageEventsUrl(usageUrl, period, page)),
1809
+ ]);
1810
+
1811
+ if (usageResult.status === "fulfilled") {
1812
+ state.data = usageResult.value;
1813
+ } else {
1814
+ state.data = null;
1815
+ state.error = getErrorMessage(usageResult.reason);
1816
+ }
1817
+
1818
+ if (summaryResult.status === "fulfilled") {
1819
+ state.tokenUsageSummary = summaryResult.value;
1820
+ } else if (
1821
+ isMissingTokenUsageEndpointError(summaryResult.reason)
1822
+ ) {
1823
+ state.tokenUsageSummary = null;
1824
+ state.tokenUsageSummaryError = null;
1825
+ } else {
1826
+ state.tokenUsageSummary = null;
1827
+ state.tokenUsageSummaryError = getErrorMessage(
1828
+ summaryResult.reason
1829
+ );
1830
+ }
1831
+
1832
+ if (dailyResult.status === "fulfilled") {
1833
+ state.tokenUsageDailySummary = dailyResult.value;
1834
+ } else if (
1835
+ isMissingTokenUsageEndpointError(dailyResult.reason)
1836
+ ) {
1837
+ state.tokenUsageDailySummary = null;
1838
+ state.tokenUsageDailyError = null;
1839
+ } else {
1840
+ state.tokenUsageDailySummary = null;
1841
+ state.tokenUsageDailyError = getErrorMessage(
1842
+ dailyResult.reason
1843
+ );
1844
+ }
1845
+
1846
+ if (eventsResult.status === "fulfilled") {
1847
+ state.tokenUsageEventsPage = eventsResult.value;
1848
+ } else if (
1849
+ isMissingTokenUsageEndpointError(eventsResult.reason)
1850
+ ) {
1851
+ state.tokenUsageEventsPage = null;
1852
+ state.tokenUsageEventsError = null;
1853
+ } else {
1854
+ state.tokenUsageEventsPage = null;
1855
+ state.tokenUsageEventsError = getErrorMessage(
1856
+ eventsResult.reason
1857
+ );
1858
+ }
1859
+ } catch (error) {
1860
+ console.error("Fetch error:", error);
1861
+ state.data = null;
1862
+ state.tokenUsageDailySummary = null;
1863
+ state.tokenUsageSummary = null;
1864
+ state.tokenUsageEventsPage = null;
1865
+ state.error = getErrorMessage(error);
1866
+ state.tokenUsageDailyError = getErrorMessage(error);
1867
+ state.tokenUsageSummaryError = getErrorMessage(error);
1868
+ state.tokenUsageEventsError = getErrorMessage(error);
1869
+ } finally {
1870
+ state.isLoading = false;
1871
+ render();
1872
+ }
1873
+ }
1874
+
1875
+ async function fetchTokenUsageSummaryAndEvents(page = 1) {
1876
+ const endpoint = endpointUrlInput.value.trim();
1877
+ if (!endpoint) {
1878
+ state.tokenUsageDailySummary = null;
1879
+ state.tokenUsageSummary = null;
1880
+ state.tokenUsageEventsPage = null;
1881
+ state.tokenUsageDailyError =
1882
+ "Token usage requires a valid endpoint URL.";
1883
+ state.tokenUsageSummaryError =
1884
+ "Token usage requires a valid endpoint URL.";
1885
+ state.tokenUsageEventsError = null;
1886
+ render();
1887
+ return;
1888
+ }
1889
+
1890
+ const period = getSelectedPeriod();
1891
+ state.isTokenUsageLoading = true;
1892
+ state.tokenUsageDailySummary = null;
1893
+ state.tokenUsageSummary = null;
1894
+ state.tokenUsageEventsPage = null;
1895
+ state.tokenUsageDailyError = null;
1896
+ state.tokenUsageSummaryError = null;
1897
+ state.tokenUsageEventsError = null;
1898
+ render();
1899
+
1900
+ try {
1901
+ const usageUrl = resolveUsageUrl(endpoint);
1902
+ const [summaryResult, dailyResult, eventsResult] =
1903
+ await Promise.allSettled([
1904
+ fetchJson(buildTokenUsageSummaryUrl(usageUrl, period)),
1905
+ fetchJson(buildTokenUsageDailyUrl(usageUrl, period)),
1906
+ fetchJson(buildTokenUsageEventsUrl(usageUrl, period, page)),
1907
+ ]);
1908
+
1909
+ if (summaryResult.status === "fulfilled") {
1910
+ state.tokenUsageSummary = summaryResult.value;
1911
+ } else if (
1912
+ isMissingTokenUsageEndpointError(summaryResult.reason)
1913
+ ) {
1914
+ state.tokenUsageSummary = null;
1915
+ state.tokenUsageSummaryError = null;
1916
+ } else {
1917
+ state.tokenUsageSummary = null;
1918
+ state.tokenUsageSummaryError = getErrorMessage(
1919
+ summaryResult.reason
1920
+ );
1921
+ }
1922
+
1923
+ if (dailyResult.status === "fulfilled") {
1924
+ state.tokenUsageDailySummary = dailyResult.value;
1925
+ } else if (
1926
+ isMissingTokenUsageEndpointError(dailyResult.reason)
1927
+ ) {
1928
+ state.tokenUsageDailySummary = null;
1929
+ state.tokenUsageDailyError = null;
1930
+ } else {
1931
+ state.tokenUsageDailySummary = null;
1932
+ state.tokenUsageDailyError = getErrorMessage(
1933
+ dailyResult.reason
1934
+ );
1935
+ }
1936
+
1937
+ if (eventsResult.status === "fulfilled") {
1938
+ state.tokenUsageEventsPage = eventsResult.value;
1939
+ } else if (
1940
+ isMissingTokenUsageEndpointError(eventsResult.reason)
1941
+ ) {
1942
+ state.tokenUsageEventsPage = null;
1943
+ state.tokenUsageEventsError = null;
1944
+ } else {
1945
+ state.tokenUsageEventsPage = null;
1946
+ state.tokenUsageEventsError = getErrorMessage(
1947
+ eventsResult.reason
1948
+ );
1949
+ }
1950
+ } catch (error) {
1951
+ console.error("Token usage fetch error:", error);
1952
+ state.tokenUsageDailySummary = null;
1953
+ state.tokenUsageSummary = null;
1954
+ state.tokenUsageEventsPage = null;
1955
+ state.tokenUsageDailyError = getErrorMessage(error);
1956
+ state.tokenUsageSummaryError = getErrorMessage(error);
1957
+ state.tokenUsageEventsError = getErrorMessage(error);
1958
+ } finally {
1959
+ state.isTokenUsageLoading = false;
1960
+ render();
1961
+ }
1962
+ }
1963
+
1964
+ async function fetchTokenUsageEventsPage(page) {
1965
+ const endpoint = endpointUrlInput.value.trim();
1966
+ if (!endpoint) {
1967
+ state.tokenUsageEventsError =
1968
+ "Token usage requires a valid endpoint URL.";
1969
+ render();
1970
+ return;
1971
+ }
1972
+
1973
+ state.isEventsLoading = true;
1974
+ state.tokenUsageEventsError = null;
1975
+ render();
1976
+
1977
+ try {
1978
+ const usageUrl = resolveUsageUrl(endpoint);
1979
+ state.tokenUsageEventsPage = await fetchJson(
1980
+ buildTokenUsageEventsUrl(usageUrl, getSelectedPeriod(), page)
1981
+ );
1982
+ } catch (error) {
1983
+ if (isMissingTokenUsageEndpointError(error)) {
1984
+ state.tokenUsageEventsPage = null;
1985
+ state.tokenUsageEventsError = null;
1986
+ } else {
1987
+ console.error("Token usage events fetch error:", error);
1988
+ state.tokenUsageEventsError = getErrorMessage(error);
1989
+ }
1990
+ } finally {
1991
+ state.isEventsLoading = false;
1992
+ render();
1993
+ }
1994
+ }
1995
+
1996
+ // --- Event Handlers & Initialization ---
1997
+
1998
+ /**
1999
+ * Handles the form submission to trigger a data fetch.
2000
+ * @param {Event} event - The form submission event.
2001
+ */
2002
+ function handleFormSubmit(event) {
2003
+ event.preventDefault();
2004
+ storeApiKey(apiKeyInput.value);
2005
+ syncUrlState();
2006
+ void fetchData();
2007
+ }
2008
+
2009
+ function handlePeriodChange() {
2010
+ syncUrlState();
2011
+ void fetchTokenUsageSummaryAndEvents(1);
2012
+ }
2013
+
2014
+ function handleContentAreaClick(event) {
2015
+ if (!(event.target instanceof Element)) {
2016
+ return;
2017
+ }
2018
+
2019
+ const trendMetric = event.target.closest("[data-trend-metric]");
2020
+ if (trendMetric) {
2021
+ state.tokenUsageTrendMetric =
2022
+ trendMetric.getAttribute("data-trend-metric")
2023
+ || ALL_METRICS_VALUE;
2024
+ render();
2025
+ return;
2026
+ }
2027
+
2028
+ const trendPoint = event.target.closest("[data-trend-day-index]");
2029
+ if (trendPoint) {
2030
+ const index = Number.parseInt(
2031
+ trendPoint.getAttribute("data-trend-day-index") || "",
2032
+ 10
2033
+ );
2034
+ if (Number.isFinite(index) && index >= 0) {
2035
+ state.tokenUsageTrendDayIndex = index;
2036
+ render();
2037
+ }
2038
+ return;
2039
+ }
2040
+
2041
+ const actionButton = event.target.closest("[data-page-action]");
2042
+ if (!actionButton || !state.tokenUsageEventsPage || state.isEventsLoading) {
2043
+ return;
2044
+ }
2045
+
2046
+ const action = actionButton.getAttribute("data-page-action");
2047
+ if (
2048
+ action === "previous"
2049
+ && state.tokenUsageEventsPage.page > 1
2050
+ ) {
2051
+ void fetchTokenUsageEventsPage(state.tokenUsageEventsPage.page - 1);
2052
+ }
2053
+
2054
+ if (
2055
+ action === "next"
2056
+ && state.tokenUsageEventsPage.page
2057
+ < state.tokenUsageEventsPage.total_pages
2058
+ ) {
2059
+ void fetchTokenUsageEventsPage(state.tokenUsageEventsPage.page + 1);
2060
+ }
2061
+ }
2062
+
2063
+ function handleContentAreaChange(event) {
2064
+ if (!(event.target instanceof HTMLSelectElement)) {
2065
+ return;
2066
+ }
2067
+
2068
+ if (!event.target.matches("[data-token-usage-trend-model]")) {
2069
+ return;
2070
+ }
2071
+
2072
+ state.tokenUsageTrendModel =
2073
+ event.target.value || ALL_MODELS_VALUE;
2074
+ render();
2075
+ }
2076
+
2077
+ /**
2078
+ * Initializes the application.
2079
+ */
2080
+ function init() {
2081
+ endpointForm.addEventListener("submit", handleFormSubmit);
2082
+ tokenUsagePeriodSelect.addEventListener("change", handlePeriodChange);
2083
+ apiKeyInput.addEventListener("input", () => {
2084
+ storeApiKey(apiKeyInput.value);
2085
+ });
2086
+ contentArea.addEventListener("click", handleContentAreaClick);
2087
+ contentArea.addEventListener("change", handleContentAreaChange);
2088
+
2089
+ apiKeyInput.value = loadStoredApiKey();
2090
+
2091
+ const urlParams = new URLSearchParams(window.location.search);
2092
+ const periodFromUrl = normalizePeriod(urlParams.get("period"));
2093
+ const endpointFromUrl = urlParams.get("endpoint");
2094
+
2095
+ state.tokenUsagePeriod = periodFromUrl;
2096
+ tokenUsagePeriodSelect.value = periodFromUrl;
2097
+
2098
+ if (endpointFromUrl) {
2099
+ endpointUrlInput.value = endpointFromUrl;
2100
+ void fetchData();
2101
+ } else {
2102
+ endpointUrlInput.value = DEFAULT_ENDPOINT;
2103
+ render();
2104
+ }
2105
+ }
2106
+
2107
+ // Start the app
2108
+ init();
2109
+ });
2110
+ </script>
2111
+
2112
+ </body>
2113
+ </html>