xiaodcs-copilot-api-edge 2.3.9-edge.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2257 @@
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">Auth credential</label>
411
+ <input
412
+ type="password"
413
+ id="x-api-key"
414
+ class="input-control input-focus"
415
+ placeholder="x-api-key or Authorization Bearer"
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
+ <p class="mt-2 text-xs" style="color: var(--color-gray);">
437
+ Credentials are remembered per endpoint origin and are not sent to other endpoints.
438
+ </p>
439
+ </header>
440
+
441
+ <main id="content-area" class="content-area"></main>
442
+ </div>
443
+ </div>
444
+
445
+ <script>
446
+ document.addEventListener("DOMContentLoaded", () => {
447
+ const endpointForm = document.getElementById("endpoint-form");
448
+ const endpointUrlInput = document.getElementById("endpoint-url");
449
+ const apiKeyInput = document.getElementById("x-api-key");
450
+ const tokenUsagePeriodSelect = document.getElementById(
451
+ "token-usage-period"
452
+ );
453
+ const contentArea = document.getElementById("content-area");
454
+ const fetchButton = document.getElementById("fetch-button");
455
+ const fetchButtonLabel = fetchButton.querySelector("span");
456
+
457
+ // Apply hover effect for fetch button via JS
458
+ fetchButton.addEventListener("mouseenter", () => {
459
+ fetchButton.style.backgroundColor = "var(--color-blue-accent)";
460
+ });
461
+ fetchButton.addEventListener("mouseleave", () => {
462
+ fetchButton.style.backgroundColor = "var(--color-blue)";
463
+ });
464
+
465
+ const DEFAULT_ENDPOINT = "http://localhost:4141/usage";
466
+ const DEFAULT_TOKEN_USAGE_PERIOD = "day";
467
+ const DEFAULT_TOKEN_USAGE_EVENTS_PAGE_SIZE = 20;
468
+ const ALL_METRICS_VALUE = "__all__";
469
+ const ALL_MODELS_VALUE = "__all__";
470
+ const LEGACY_API_KEY_STORAGE_KEY = "copilot-api.usage-viewer.x-api-key";
471
+ const CREDENTIAL_STORAGE_PREFIX = "copilot-api.usage-viewer.credential.";
472
+ const VALID_PERIODS = new Set(["day", "week", "month"]);
473
+ const EMPTY_TOKEN_USAGE_TOTALS = {
474
+ cache_creation_input_tokens: 0,
475
+ cache_read_input_tokens: 0,
476
+ costs: [],
477
+ input_tokens: 0,
478
+ output_tokens: 0,
479
+ request_count: 0,
480
+ total_nano_aiu: null,
481
+ total_tokens: 0,
482
+ };
483
+
484
+ // --- State Management ---
485
+ const state = {
486
+ isLoading: false,
487
+ isTokenUsageLoading: false,
488
+ isEventsLoading: false,
489
+ error: null,
490
+ data: null,
491
+ tokenUsageDailySummary: null,
492
+ tokenUsageSummary: null,
493
+ tokenUsageEventsPage: null,
494
+ tokenUsageDailyError: null,
495
+ tokenUsageSummaryError: null,
496
+ tokenUsageEventsError: null,
497
+ tokenUsagePeriod: DEFAULT_TOKEN_USAGE_PERIOD,
498
+ tokenUsageTrendDayIndex: null,
499
+ tokenUsageTrendMetric: ALL_METRICS_VALUE,
500
+ tokenUsageTrendModel: ALL_MODELS_VALUE,
501
+ };
502
+
503
+ // --- Rendering Logic ---
504
+
505
+ /**
506
+ * Safely calls lucide.createIcons() if the library is available.
507
+ */
508
+ function createIcons() {
509
+ if (typeof lucide !== "undefined") {
510
+ lucide.createIcons();
511
+ }
512
+ }
513
+
514
+ function escapeHtml(value) {
515
+ return String(value)
516
+ .replaceAll("&", "&amp;")
517
+ .replaceAll("<", "&lt;")
518
+ .replaceAll(">", "&gt;")
519
+ .replaceAll('"', "&quot;")
520
+ .replaceAll("'", "&#39;");
521
+ }
522
+
523
+ function getErrorMessage(error) {
524
+ if (error instanceof Error) {
525
+ return error.message;
526
+ }
527
+ return typeof error === "string" ? error : "Unknown error.";
528
+ }
529
+
530
+ function isMissingTokenUsageEndpointError(error) {
531
+ return Boolean(
532
+ error
533
+ && typeof error === "object"
534
+ && "status" in error
535
+ && error.status === 404
536
+ );
537
+ }
538
+
539
+ function normalizePeriod(value) {
540
+ return VALID_PERIODS.has(value)
541
+ ? value
542
+ : DEFAULT_TOKEN_USAGE_PERIOD;
543
+ }
544
+
545
+ function getSelectedPeriod() {
546
+ const normalized = normalizePeriod(tokenUsagePeriodSelect.value);
547
+ tokenUsagePeriodSelect.value = normalized;
548
+ state.tokenUsagePeriod = normalized;
549
+ return normalized;
550
+ }
551
+
552
+ function getEndpointOrigin(endpoint) {
553
+ if (!endpoint) {
554
+ return "";
555
+ }
556
+ try {
557
+ return new URL(endpoint, window.location.href).origin;
558
+ } catch (error) {
559
+ console.warn("Could not parse endpoint URL:", getErrorMessage(error));
560
+ return "";
561
+ }
562
+ }
563
+
564
+ function getCredentialStorageKey(endpoint) {
565
+ const origin = getEndpointOrigin(endpoint);
566
+ return origin ? `${CREDENTIAL_STORAGE_PREFIX}${origin}` : "";
567
+ }
568
+
569
+ function readStoredApiKey(endpoint) {
570
+ const storageKey = getCredentialStorageKey(endpoint);
571
+ if (!storageKey) {
572
+ return "";
573
+ }
574
+ try {
575
+ return window.localStorage.getItem(storageKey) || "";
576
+ } catch (error) {
577
+ console.warn(
578
+ "Could not read stored credential:",
579
+ getErrorMessage(error)
580
+ );
581
+ return "";
582
+ }
583
+ }
584
+
585
+ function writeStoredApiKey(endpoint, value) {
586
+ const storageKey = getCredentialStorageKey(endpoint);
587
+ if (!storageKey) {
588
+ return;
589
+ }
590
+ try {
591
+ window.localStorage.setItem(storageKey, value);
592
+ } catch (error) {
593
+ console.warn(
594
+ "Could not store credential:",
595
+ getErrorMessage(error)
596
+ );
597
+ }
598
+ }
599
+
600
+ function removeStoredApiKey(endpoint) {
601
+ const storageKey = getCredentialStorageKey(endpoint);
602
+ if (!storageKey) {
603
+ return;
604
+ }
605
+ try {
606
+ window.localStorage.removeItem(storageKey);
607
+ } catch (error) {
608
+ console.warn(
609
+ "Could not remove stored credential:",
610
+ getErrorMessage(error)
611
+ );
612
+ }
613
+ }
614
+
615
+ function removeLegacyStoredApiKey() {
616
+ try {
617
+ window.localStorage.removeItem(LEGACY_API_KEY_STORAGE_KEY);
618
+ } catch (error) {
619
+ console.warn(
620
+ "Could not remove legacy credential:",
621
+ getErrorMessage(error)
622
+ );
623
+ }
624
+ }
625
+
626
+ function migrateLegacyApiKey(endpoint) {
627
+ const storageKey = getCredentialStorageKey(endpoint);
628
+ if (storageKey && getEndpointOrigin(endpoint) === window.location.origin) {
629
+ try {
630
+ const legacyValue = window.localStorage.getItem(
631
+ LEGACY_API_KEY_STORAGE_KEY
632
+ );
633
+ if (legacyValue && !window.localStorage.getItem(storageKey)) {
634
+ window.localStorage.setItem(storageKey, legacyValue);
635
+ }
636
+ } catch (error) {
637
+ console.warn(
638
+ "Could not migrate legacy credential:",
639
+ getErrorMessage(error)
640
+ );
641
+ }
642
+ }
643
+ removeLegacyStoredApiKey();
644
+ }
645
+
646
+ function loadStoredApiKey(endpoint) {
647
+ return readStoredApiKey(endpoint);
648
+ }
649
+
650
+ function storeApiKey(value, endpoint) {
651
+ const trimmed = value.trim();
652
+ if (trimmed) {
653
+ writeStoredApiKey(endpoint, trimmed);
654
+ } else {
655
+ removeStoredApiKey(endpoint);
656
+ }
657
+ }
658
+
659
+ function parseCredential(value) {
660
+ const trimmed = value.trim();
661
+ if (!trimmed) {
662
+ return null;
663
+ }
664
+
665
+ const authorizationMatch = trimmed.match(
666
+ /^authorization\s*:\s*bearer\s+(.+)$/i
667
+ );
668
+ if (authorizationMatch) {
669
+ return {
670
+ header: "authorization",
671
+ value: `Bearer ${authorizationMatch[1].trim()}`,
672
+ };
673
+ }
674
+
675
+ const bearerMatch = trimmed.match(/^bearer\s+(.+)$/i);
676
+ if (bearerMatch) {
677
+ return {
678
+ header: "authorization",
679
+ value: `Bearer ${bearerMatch[1].trim()}`,
680
+ };
681
+ }
682
+
683
+ const xApiKeyMatch = trimmed.match(/^x-api-key\s*:\s*(.+)$/i);
684
+ if (xApiKeyMatch) {
685
+ return {
686
+ header: "x-api-key",
687
+ value: xApiKeyMatch[1].trim(),
688
+ };
689
+ }
690
+
691
+ return {
692
+ header: "x-api-key",
693
+ value: trimmed,
694
+ };
695
+ }
696
+
697
+ function buildRequestHeaders() {
698
+ const headers = {};
699
+ const credential = parseCredential(apiKeyInput.value);
700
+ if (credential) {
701
+ headers[credential.header] = credential.value;
702
+ }
703
+ return headers;
704
+ }
705
+
706
+ function resolveUsageUrl(endpoint) {
707
+ return new URL(endpoint, window.location.href).toString();
708
+ }
709
+
710
+ function deriveEndpointUrl(usageEndpoint, targetPath) {
711
+ const url = new URL(usageEndpoint, window.location.href);
712
+ const normalizedTargetPath = targetPath.replace(/^\/+/, "");
713
+ const normalizedPath = url.pathname.replace(/\/+$/, "");
714
+ const nextPath = normalizedPath.endsWith("/usage")
715
+ ? normalizedPath.replace(/\/usage$/, `/${normalizedTargetPath}`)
716
+ : `${normalizedPath || ""}/${normalizedTargetPath}`;
717
+
718
+ url.pathname = nextPath.startsWith("/") ? nextPath : `/${nextPath}`;
719
+ url.search = "";
720
+ url.hash = "";
721
+ return url.toString();
722
+ }
723
+
724
+ function buildTokenUsageSummaryUrl(usageEndpoint, period) {
725
+ const url = new URL(deriveEndpointUrl(usageEndpoint, "token-usage"));
726
+ url.searchParams.set("period", period);
727
+ return url.toString();
728
+ }
729
+
730
+ function buildTokenUsageDailyUrl(usageEndpoint, period) {
731
+ const url = new URL(
732
+ deriveEndpointUrl(usageEndpoint, "token-usage/daily")
733
+ );
734
+ url.searchParams.set("period", period);
735
+ return url.toString();
736
+ }
737
+
738
+ function buildTokenUsageEventsUrl(usageEndpoint, period, page) {
739
+ const url = new URL(
740
+ deriveEndpointUrl(usageEndpoint, "token-usage/events")
741
+ );
742
+ url.searchParams.set("period", period);
743
+ url.searchParams.set("page", String(page));
744
+ url.searchParams.set(
745
+ "page_size",
746
+ String(DEFAULT_TOKEN_USAGE_EVENTS_PAGE_SIZE)
747
+ );
748
+ return url.toString();
749
+ }
750
+
751
+ async function fetchJson(url) {
752
+ const response = await fetch(url, {
753
+ headers: buildRequestHeaders(),
754
+ });
755
+
756
+ if (!response.ok) {
757
+ let message = response.statusText || "Request failed";
758
+
759
+ try {
760
+ const contentType = response.headers.get("content-type") || "";
761
+ if (contentType.includes("application/json")) {
762
+ const payload = await response.json();
763
+ const apiMessage = payload?.error?.message || payload?.message;
764
+ if (typeof apiMessage === "string" && apiMessage.trim()) {
765
+ message = apiMessage.trim();
766
+ }
767
+ } else {
768
+ const text = await response.text();
769
+ if (text.trim()) {
770
+ message = text.trim();
771
+ }
772
+ }
773
+ } catch (error) {
774
+ console.warn(
775
+ "Could not parse error response:",
776
+ getErrorMessage(error)
777
+ );
778
+ }
779
+
780
+ const error = new Error(
781
+ `Request failed with status ${response.status}: ${message}`
782
+ );
783
+ error.status = response.status;
784
+ throw error;
785
+ }
786
+
787
+ return response.json();
788
+ }
789
+
790
+ function syncUrlState() {
791
+ try {
792
+ const currentUrl = new URL(window.location.href);
793
+ const endpoint = endpointUrlInput.value.trim();
794
+ const period = getSelectedPeriod();
795
+
796
+ if (endpoint) {
797
+ currentUrl.searchParams.set("endpoint", endpoint);
798
+ } else {
799
+ currentUrl.searchParams.delete("endpoint");
800
+ }
801
+
802
+ currentUrl.searchParams.set("period", period);
803
+ window.history.pushState({}, "", currentUrl);
804
+ } catch (error) {
805
+ console.warn("Could not update URL:", getErrorMessage(error));
806
+ }
807
+ }
808
+
809
+ function updateControls() {
810
+ fetchButton.disabled = state.isLoading;
811
+ fetchButtonLabel.textContent = state.isLoading
812
+ ? "Refreshing..."
813
+ : "Refresh";
814
+ tokenUsagePeriodSelect.disabled =
815
+ state.isLoading || state.isTokenUsageLoading;
816
+ }
817
+
818
+ function formatNumber(value) {
819
+ return Number.isFinite(value) ? Number(value).toLocaleString() : "0";
820
+ }
821
+
822
+ function getMetricDisplayText(value) {
823
+ return typeof value === "number" ? formatNumber(value) : value;
824
+ }
825
+
826
+ function resolveMetricValueFontSize(displayTexts) {
827
+ const cards = Array.isArray(displayTexts)
828
+ ? displayTexts
829
+ : [displayTexts];
830
+ const longest = cards.reduce((max, cardText) => {
831
+ const lines = Array.isArray(cardText) ? cardText : [cardText];
832
+ const cardMax = lines.reduce((m, line) => {
833
+ return Math.max(m, String(line ?? "").length);
834
+ }, 0);
835
+ return Math.max(max, cardMax);
836
+ }, 0);
837
+ if (longest <= 7) return "";
838
+ if (longest <= 11) return "clamp(15px, 1.3vw, 18px)";
839
+ if (longest <= 15) return "clamp(13px, 1.1vw, 16px)";
840
+ return "clamp(11px, 1vw, 14px)";
841
+ }
842
+
843
+ function formatCurrencyAmount(currency, amount) {
844
+ const symbols = {
845
+ CNY: "¥",
846
+ USD: "$",
847
+ };
848
+ const normalizedCurrency =
849
+ typeof currency === "string" && currency.trim()
850
+ ? currency.trim().toUpperCase()
851
+ : "";
852
+ const symbol = symbols[normalizedCurrency] || "";
853
+ return symbol
854
+ ? `${symbol}${Number(amount).toFixed(6)}`
855
+ : `${normalizedCurrency} ${Number(amount).toFixed(6)}`;
856
+ }
857
+
858
+ function getCostAmount(cost) {
859
+ if (Number.isFinite(cost?.amount)) {
860
+ return Number(cost.amount);
861
+ }
862
+ if (Number.isFinite(cost?.total_cost_nanos)) {
863
+ return Number(cost.total_cost_nanos) / 1_000_000_000;
864
+ }
865
+ return null;
866
+ }
867
+
868
+ function formatTokenUsageCost(cost) {
869
+ const amount = getCostAmount(cost);
870
+ return amount === null || !cost?.currency
871
+ ? "—"
872
+ : formatCurrencyAmount(cost.currency, amount);
873
+ }
874
+
875
+ function formatTokenUsageCosts(costs) {
876
+ if (!Array.isArray(costs) || costs.length === 0) {
877
+ return ["—"];
878
+ }
879
+ return costs.map((cost) => formatTokenUsageCost(cost));
880
+ }
881
+
882
+ function formatDateTime(value) {
883
+ if (!Number.isFinite(value)) {
884
+ return "N/A";
885
+ }
886
+ return new Date(value).toLocaleString();
887
+ }
888
+
889
+ function formatCellText(value) {
890
+ if (typeof value !== "string") {
891
+ return "—";
892
+ }
893
+
894
+ const trimmed = value.trim();
895
+ return trimmed || "—";
896
+ }
897
+
898
+ function renderTokenUsageRangeText(period, range) {
899
+ const labels = {
900
+ day: "Day window",
901
+ week: "Last 7 days",
902
+ month: "Last 30 days",
903
+ };
904
+
905
+ if (!range) {
906
+ return labels[period] || labels.day;
907
+ }
908
+
909
+ return `${labels[period] || labels.day}: ${formatDateTime(
910
+ range.start_ms
911
+ )} - ${formatDateTime(range.end_ms)}`;
912
+ }
913
+
914
+ /**
915
+ * Renders the entire UI based on the current state.
916
+ */
917
+ function render() {
918
+ updateControls();
919
+
920
+ if (state.isLoading) {
921
+ contentArea.innerHTML = renderSpinner();
922
+ createIcons();
923
+ return;
924
+ }
925
+
926
+ const hasContent = Boolean(
927
+ state.error
928
+ || state.data
929
+ || state.isTokenUsageLoading
930
+ || state.isEventsLoading
931
+ || state.tokenUsageDailySummary
932
+ || state.tokenUsageSummary
933
+ || state.tokenUsageEventsPage
934
+ || state.tokenUsageDailyError
935
+ || state.tokenUsageSummaryError
936
+ || state.tokenUsageEventsError
937
+ );
938
+
939
+ if (!hasContent) {
940
+ contentArea.innerHTML = renderWelcomeMessage();
941
+ } else if (state.error && !state.data) {
942
+ contentArea.innerHTML = `
943
+ ${renderTokenUsageSection()}
944
+ ${renderUsageErrorSection()}
945
+ `;
946
+ } else if (state.data) {
947
+ contentArea.innerHTML = `
948
+ ${renderTokenUsageSection()}
949
+ ${renderUsageQuotas(state.data.quota_snapshots)}
950
+ ${renderDetailedData(state.data)}
951
+ `;
952
+ } else {
953
+ contentArea.innerHTML = renderTokenUsageSection();
954
+ }
955
+
956
+ // Replace placeholder icons with actual Lucide icons
957
+ createIcons();
958
+ }
959
+
960
+ /**
961
+ * Renders the "Usage Quotas" section with progress bars.
962
+ * @param {object} snapshots - The quota_snapshots object from the API response.
963
+ * @returns {string} HTML string for the usage quotas section.
964
+ */
965
+ function renderUsageQuotas(snapshots) {
966
+ if (!snapshots) return "";
967
+
968
+ const quotaCards = Object.entries(snapshots)
969
+ .map(([key, value]) => {
970
+ return renderQuotaCard(key, value);
971
+ })
972
+ .join("");
973
+
974
+ return `
975
+ <section id="usage-quotas" class="dashboard-section">
976
+ <h2 class="section-heading">
977
+ <i data-lucide="gauge" class="h-5 w-5" style="color: var(--color-blue);"></i>
978
+ <span>Copilot Quotas</span>
979
+ </h2>
980
+ <div class="metric-grid">
981
+ ${quotaCards}
982
+ </div>
983
+ </section>
984
+ `;
985
+ }
986
+
987
+ function renderUsageErrorSection() {
988
+ if (!state.error || state.data) {
989
+ return "";
990
+ }
991
+
992
+ return `
993
+ <section id="usage-quotas" class="dashboard-section">
994
+ <h2 class="section-heading">
995
+ <i data-lucide="gauge" class="h-5 w-5" style="color: var(--color-blue);"></i>
996
+ <span>Copilot Quotas</span>
997
+ </h2>
998
+ ${renderError(state.error, "Usage request failed")}
999
+ </section>
1000
+ `;
1001
+ }
1002
+
1003
+ /**
1004
+ * Renders a single quota card.
1005
+ * @param {string} title - The name of the quota (e.g., 'chat').
1006
+ * @param {object} details - The details object for the quota.
1007
+ * @returns {string} HTML string for a single card.
1008
+ */
1009
+ function renderQuotaCard(title, details) {
1010
+ const { entitlement, remaining, percent_remaining, unlimited } =
1011
+ details;
1012
+
1013
+ const percentUsed = unlimited ? 0 : 100 - percent_remaining;
1014
+ const used = unlimited
1015
+ ? "N/A"
1016
+ : (entitlement - remaining).toLocaleString();
1017
+
1018
+ let progressBarColor = "var(--color-green)";
1019
+ if (percentUsed > 75) progressBarColor = "var(--color-yellow)";
1020
+ if (percentUsed > 90) progressBarColor = "var(--color-red)";
1021
+ if (unlimited) progressBarColor = "var(--color-blue)";
1022
+
1023
+ return `
1024
+ <div class="metric-card">
1025
+ <div class="flex items-center justify-between gap-3">
1026
+ <div class="metric-label capitalize">
1027
+ <span class="metric-dot" style="background-color: ${progressBarColor};"></span>
1028
+ <span>${escapeHtml(title.replace(/_/g, " "))}</span>
1029
+ </div>
1030
+ ${
1031
+ unlimited
1032
+ ? `<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>`
1033
+ : `<span class="text-xs font-semibold" style="color: var(--color-gray);">${percentUsed.toFixed(1)}% used</span>`
1034
+ }
1035
+ </div>
1036
+ <div class="mt-5">
1037
+ <div class="w-full progress-bar-bg h-2">
1038
+ <div class="progress-bar-fg h-2" style="width: ${unlimited ? 100 : percentUsed}%; background-color: ${progressBarColor};"></div>
1039
+ </div>
1040
+ </div>
1041
+ <div class="mt-2 flex items-center justify-between gap-4 text-xs font-medium" style="color: var(--color-gray);">
1042
+ <span>${used} / ${unlimited ? "∞" : entitlement.toLocaleString()}</span>
1043
+ <span>${unlimited ? "∞" : remaining.toLocaleString()} remaining</span>
1044
+ </div>
1045
+ </div>
1046
+ `;
1047
+ }
1048
+
1049
+ /**
1050
+ * Recursively builds a formatted HTML list from a JSON object.
1051
+ * @param {object} obj - The object to format.
1052
+ * @returns {string} HTML string for the formatted list.
1053
+ */
1054
+ function formatObject(obj) {
1055
+ if (obj === null || typeof obj !== "object") {
1056
+ return `<span style="color: var(--color-green);">${escapeHtml(JSON.stringify(obj))}</span>`;
1057
+ }
1058
+
1059
+ return (
1060
+ '<div class="pl-4">' +
1061
+ Object.entries(obj)
1062
+ .map(([key, value]) => {
1063
+ const formattedKey = escapeHtml(key.replace(/_/g, " "));
1064
+ let displayValue;
1065
+
1066
+ if (Array.isArray(value)) {
1067
+ displayValue =
1068
+ value.length > 0
1069
+ ? `<span style='color: var(--color-gray)'>[...${value.length} items]</span>`
1070
+ : `<span style='color: var(--color-gray)'>[]</span>`;
1071
+ } else if (typeof value === "object" && value !== null) {
1072
+ displayValue = formatObject(value);
1073
+ } else if (typeof value === "boolean") {
1074
+ displayValue = `<span class="font-semibold" style="color: ${value ? "var(--color-green)" : "var(--color-red)"}">${escapeHtml(String(value))}</span>`;
1075
+ } else {
1076
+ displayValue = `<span style="color: var(--color-blue);">${escapeHtml(JSON.stringify(value))}</span>`;
1077
+ }
1078
+
1079
+ return `<div class="mt-1">
1080
+ <span class="capitalize font-semibold" style="color: var(--color-fg-lightest);">${formattedKey}:</span>
1081
+ ${typeof value === "object" && value !== null && !Array.isArray(value) ? displayValue : ` ${displayValue}`}
1082
+ </div>`;
1083
+ })
1084
+ .join("") +
1085
+ "</div>"
1086
+ );
1087
+ }
1088
+
1089
+ /**
1090
+ * Renders the section with the full, formatted API response.
1091
+ * @param {object} data - The full API response data.
1092
+ * @returns {string} HTML string for the full data section.
1093
+ */
1094
+ function renderDetailedData(data) {
1095
+ if (!data || Object.keys(data).length === 0) {
1096
+ return "";
1097
+ }
1098
+
1099
+ const formattedDetails = formatObject(data);
1100
+ return `
1101
+ <section id="detailed-data" class="dashboard-section">
1102
+ <h2 class="section-heading">
1103
+ <i data-lucide="file-text" class="h-5 w-5" style="color: var(--color-blue);"></i>
1104
+ <span>Copilot Usage API Response</span>
1105
+ </h2>
1106
+ <div class="panel p-4 relative font-mono text-xs code-block overflow-auto" style="max-height: 420px;">
1107
+ ${formattedDetails}
1108
+ </div>
1109
+ </section>
1110
+ `;
1111
+ }
1112
+
1113
+ function renderTokenUsageSection() {
1114
+ const hasTokenUsageContent = Boolean(
1115
+ state.isTokenUsageLoading
1116
+ || state.isEventsLoading
1117
+ || state.tokenUsageDailySummary
1118
+ || state.tokenUsageSummary
1119
+ || state.tokenUsageEventsPage
1120
+ || state.tokenUsageDailyError
1121
+ || state.tokenUsageSummaryError
1122
+ || state.tokenUsageEventsError
1123
+ );
1124
+
1125
+ if (!hasTokenUsageContent) {
1126
+ return "";
1127
+ }
1128
+
1129
+ const summary = state.tokenUsageSummary;
1130
+ const dailySummary = state.tokenUsageDailySummary;
1131
+ const eventsPage = state.tokenUsageEventsPage;
1132
+ const totals = summary?.totals || EMPTY_TOKEN_USAGE_TOTALS;
1133
+ const activeRange =
1134
+ summary?.range || dailySummary?.range || eventsPage?.range || null;
1135
+ const totalPages = eventsPage ? Math.max(eventsPage.total_pages, 1) : 1;
1136
+ const eventsMeta = eventsPage
1137
+ ? `Page ${eventsPage.page} / ${totalPages} - ${formatNumber(
1138
+ eventsPage.total
1139
+ )} events`
1140
+ : "No detail page loaded yet.";
1141
+
1142
+ return `
1143
+ <section id="token-usage" class="dashboard-section">
1144
+ <div class="mb-2 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
1145
+ <div>
1146
+ <h2 class="section-heading mb-1">
1147
+ <i data-lucide="database" class="h-5 w-5" style="color: var(--color-blue);"></i>
1148
+ <span>Token Usage</span>
1149
+ </h2>
1150
+ <p class="section-subtitle mb-0">${escapeHtml(
1151
+ renderTokenUsageRangeText(state.tokenUsagePeriod, activeRange)
1152
+ )}</p>
1153
+ </div>
1154
+ <div class="flex flex-wrap items-center gap-2 text-xs font-medium" style="color: var(--color-gray);">
1155
+ ${
1156
+ state.isTokenUsageLoading
1157
+ ? `<span class="rounded-md px-2 py-1" style="background-color: rgba(37, 99, 235, 0.08); color: var(--color-blue);">Refreshing summary...</span>`
1158
+ : ""
1159
+ }
1160
+ ${
1161
+ state.isEventsLoading
1162
+ ? `<span class="rounded-md px-2 py-1" style="background-color: rgba(37, 99, 235, 0.08); color: var(--color-blue);">Refreshing details...</span>`
1163
+ : ""
1164
+ }
1165
+ </div>
1166
+ </div>
1167
+
1168
+ ${
1169
+ state.tokenUsageSummaryError
1170
+ ? renderError(
1171
+ state.tokenUsageSummaryError,
1172
+ "Token usage summary failed"
1173
+ )
1174
+ : ""
1175
+ }
1176
+
1177
+ <div class="metric-grid metric-grid-tight ${
1178
+ state.isTokenUsageLoading ? "opacity-60" : ""
1179
+ }">
1180
+ ${
1181
+ (() => {
1182
+ const metricFontSize = resolveMetricValueFontSize([
1183
+ getMetricDisplayText(totals.total_tokens),
1184
+ getMetricDisplayText(totals.input_tokens),
1185
+ getMetricDisplayText(totals.output_tokens),
1186
+ getMetricDisplayText(totals.cache_read_input_tokens),
1187
+ getMetricDisplayText(totals.cache_creation_input_tokens),
1188
+ getMetricDisplayText(totals.request_count),
1189
+ formatTokenUsageCosts(totals.costs),
1190
+ ]);
1191
+ return [
1192
+ renderTokenUsageMetric(
1193
+ "Total",
1194
+ totals.total_tokens,
1195
+ "var(--color-fg-lightest)",
1196
+ metricFontSize
1197
+ ),
1198
+ renderTokenUsageMetric(
1199
+ "Input",
1200
+ totals.input_tokens,
1201
+ "var(--color-blue-accent)",
1202
+ metricFontSize
1203
+ ),
1204
+ renderTokenUsageMetric(
1205
+ "Output",
1206
+ totals.output_tokens,
1207
+ "var(--color-green-accent)",
1208
+ metricFontSize
1209
+ ),
1210
+ renderTokenUsageMetric(
1211
+ "Cache Read",
1212
+ totals.cache_read_input_tokens,
1213
+ "var(--color-aqua-accent)",
1214
+ metricFontSize
1215
+ ),
1216
+ renderTokenUsageMetric(
1217
+ "Cache Write",
1218
+ totals.cache_creation_input_tokens,
1219
+ "var(--color-yellow-accent)",
1220
+ metricFontSize
1221
+ ),
1222
+ renderTokenUsageMetric(
1223
+ "Requests",
1224
+ totals.request_count,
1225
+ "var(--color-purple-accent)",
1226
+ metricFontSize
1227
+ ),
1228
+ renderTokenUsageCostMetric(
1229
+ "Cost",
1230
+ formatTokenUsageCosts(totals.costs),
1231
+ "var(--color-yellow-accent)",
1232
+ metricFontSize
1233
+ ),
1234
+ ].join("");
1235
+ })()
1236
+ }
1237
+ </div>
1238
+
1239
+ ${renderTokenUsageDailyTrend(
1240
+ dailySummary,
1241
+ state.tokenUsageDailyError
1242
+ )}
1243
+
1244
+ <div class="mt-3 grid grid-cols-1 gap-3">
1245
+ <div class="panel">
1246
+ <div class="panel-header">
1247
+ <div>
1248
+ <p class="panel-title">Model Breakdown</p>
1249
+ <p class="panel-meta">${summary ? `${formatNumber(summary.byModel.length)} models` : "No summary rows loaded."}</p>
1250
+ </div>
1251
+ </div>
1252
+ ${renderTokenUsageModelBreakdown(summary)}
1253
+ </div>
1254
+
1255
+ <div class="panel">
1256
+ <div class="panel-header flex-col items-start sm:flex-row sm:items-center">
1257
+ <div>
1258
+ <p class="panel-title">Request Events</p>
1259
+ <p class="panel-meta">${escapeHtml(eventsMeta)}</p>
1260
+ </div>
1261
+ <div class="flex items-center gap-2">
1262
+ ${renderPaginationButton(
1263
+ "previous",
1264
+ "Previous",
1265
+ !eventsPage || state.isEventsLoading || eventsPage.page <= 1
1266
+ )}
1267
+ ${renderPaginationButton(
1268
+ "next",
1269
+ "Next",
1270
+ !eventsPage
1271
+ || state.isEventsLoading
1272
+ || eventsPage.page >= totalPages
1273
+ )}
1274
+ </div>
1275
+ </div>
1276
+ ${
1277
+ state.tokenUsageEventsError
1278
+ ? `<div class="p-4">${renderError(
1279
+ state.tokenUsageEventsError,
1280
+ "Token usage details failed"
1281
+ )}</div>`
1282
+ : ""
1283
+ }
1284
+ ${renderTokenUsageEventsTable(eventsPage)}
1285
+ </div>
1286
+ </div>
1287
+ </section>
1288
+ `;
1289
+ }
1290
+
1291
+ function getTokenUsageTrendMetrics() {
1292
+ return [
1293
+ {
1294
+ color: "var(--color-fg-lightest)",
1295
+ key: "total_tokens",
1296
+ label: "Total",
1297
+ },
1298
+ {
1299
+ color: "var(--color-blue-accent)",
1300
+ key: "input_tokens",
1301
+ label: "Input",
1302
+ },
1303
+ {
1304
+ color: "var(--color-green-accent)",
1305
+ key: "output_tokens",
1306
+ label: "Output",
1307
+ },
1308
+ {
1309
+ color: "var(--color-aqua-accent)",
1310
+ key: "cache_read_input_tokens",
1311
+ label: "Cache Read",
1312
+ },
1313
+ {
1314
+ color: "var(--color-yellow-accent)",
1315
+ key: "cache_creation_input_tokens",
1316
+ label: "Cache Write",
1317
+ },
1318
+ ];
1319
+ }
1320
+
1321
+ function getDailyTrendTotals(day, selectedModel) {
1322
+ if (selectedModel === ALL_MODELS_VALUE) {
1323
+ return day.totals || EMPTY_TOKEN_USAGE_TOTALS;
1324
+ }
1325
+
1326
+ return (
1327
+ day.byModel?.find((model) => model.model === selectedModel)
1328
+ || EMPTY_TOKEN_USAGE_TOTALS
1329
+ );
1330
+ }
1331
+
1332
+ function formatChartDate(value) {
1333
+ const parts = String(value).split("-");
1334
+ return parts.length === 3 ? `${parts[1]}/${parts[2]}` : value;
1335
+ }
1336
+
1337
+ function renderTokenUsageDailyTrend(dailySummary, error) {
1338
+ if (state.tokenUsagePeriod === "day") {
1339
+ return "";
1340
+ }
1341
+
1342
+ const models = dailySummary?.byModel || [];
1343
+ const selectedModel = models.some(
1344
+ (model) => model.model === state.tokenUsageTrendModel
1345
+ )
1346
+ ? state.tokenUsageTrendModel
1347
+ : ALL_MODELS_VALUE;
1348
+ const options = [
1349
+ `<option value="${ALL_MODELS_VALUE}">All models</option>`,
1350
+ ...models.map(
1351
+ (model) =>
1352
+ `<option value="${escapeHtml(model.model)}" ${
1353
+ selectedModel === model.model ? "selected" : ""
1354
+ }>${escapeHtml(model.model)}</option>`
1355
+ ),
1356
+ ].join("");
1357
+
1358
+ return `
1359
+ <div class="mt-3 grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_304px] gap-3">
1360
+ <div class="panel">
1361
+ <div class="panel-header">
1362
+ <div>
1363
+ <p class="panel-title">Token Usage Over Time</p>
1364
+ <p class="panel-meta">${escapeHtml(
1365
+ renderTokenUsageRangeText(
1366
+ state.tokenUsagePeriod,
1367
+ dailySummary?.range || null
1368
+ )
1369
+ )}</p>
1370
+ </div>
1371
+ </div>
1372
+ ${
1373
+ error
1374
+ ? `<div class="p-4">${renderError(
1375
+ error,
1376
+ "Token usage daily trend failed"
1377
+ )}</div>`
1378
+ : ""
1379
+ }
1380
+ ${renderTokenUsageTrendChart(dailySummary, selectedModel)}
1381
+ </div>
1382
+
1383
+ <aside class="panel">
1384
+ <div class="panel-header">
1385
+ <div>
1386
+ <p class="panel-title">Filters</p>
1387
+ <p class="panel-meta">Applied to the trend chart</p>
1388
+ </div>
1389
+ </div>
1390
+ <div class="p-4 space-y-4">
1391
+ <div>
1392
+ <label class="field-label">Model</label>
1393
+ <select
1394
+ data-token-usage-trend-model
1395
+ class="select-control input-focus text-sm"
1396
+ >${options}</select>
1397
+ </div>
1398
+ <div>
1399
+ <div class="field-label">Metric</div>
1400
+ <div class="flex flex-wrap gap-2">
1401
+ ${renderTokenUsageTrendMetricButtons()}
1402
+ </div>
1403
+ </div>
1404
+ </div>
1405
+ </aside>
1406
+ </div>
1407
+ `;
1408
+ }
1409
+
1410
+ function renderTokenUsageTrendMetricButtons() {
1411
+ const metrics = getTokenUsageTrendMetrics();
1412
+ return [
1413
+ {
1414
+ color: "var(--color-fg-lightest)",
1415
+ key: ALL_METRICS_VALUE,
1416
+ label: "All",
1417
+ },
1418
+ ...metrics,
1419
+ ]
1420
+ .map((metric) => {
1421
+ const isActive = state.tokenUsageTrendMetric === metric.key;
1422
+ return `
1423
+ <button
1424
+ type="button"
1425
+ data-trend-metric="${metric.key}"
1426
+ class="pill-button inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-semibold"
1427
+ 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)"};"
1428
+ >
1429
+ <span class="h-2 w-2 rounded-full" style="background-color: ${metric.color};"></span>
1430
+ ${escapeHtml(metric.label)}
1431
+ </button>
1432
+ `;
1433
+ })
1434
+ .join("");
1435
+ }
1436
+
1437
+ function renderTokenUsageTrendChart(dailySummary, selectedModel) {
1438
+ const days = dailySummary?.days || [];
1439
+ const metrics = getTokenUsageTrendMetrics();
1440
+ const visibleMetrics =
1441
+ state.tokenUsageTrendMetric === ALL_METRICS_VALUE
1442
+ ? metrics
1443
+ : metrics.filter(
1444
+ (metric) => metric.key === state.tokenUsageTrendMetric
1445
+ );
1446
+ const hasUsage = days.some(
1447
+ (day) => getDailyTrendTotals(day, selectedModel).request_count > 0
1448
+ );
1449
+ const latestUsageIndex = days.reduce((latest, day, index) => {
1450
+ return getDailyTrendTotals(day, selectedModel).request_count > 0
1451
+ ? index
1452
+ : latest;
1453
+ }, -1);
1454
+ const activeIndex =
1455
+ Number.isInteger(state.tokenUsageTrendDayIndex)
1456
+ && state.tokenUsageTrendDayIndex >= 0
1457
+ && state.tokenUsageTrendDayIndex < days.length
1458
+ ? state.tokenUsageTrendDayIndex
1459
+ : latestUsageIndex >= 0
1460
+ ? latestUsageIndex
1461
+ : Math.max(0, days.length - 1);
1462
+ const activeDay = days[activeIndex];
1463
+ const activeTotals = activeDay
1464
+ ? getDailyTrendTotals(activeDay, selectedModel)
1465
+ : EMPTY_TOKEN_USAGE_TOTALS;
1466
+
1467
+ if (state.isTokenUsageLoading && !dailySummary) {
1468
+ return `<div class="p-4 min-h-[14rem] text-sm animate-pulse" style="color: var(--color-gray);">Loading...</div>`;
1469
+ }
1470
+
1471
+ if (!dailySummary || days.length === 0 || !hasUsage) {
1472
+ return renderEmptyState(
1473
+ "No token usage recorded for the selected period."
1474
+ );
1475
+ }
1476
+
1477
+ const width = 720;
1478
+ const height = 220;
1479
+ const padding = { bottom: 34, left: 58, right: 16, top: 16 };
1480
+ const plotWidth = width - padding.left - padding.right;
1481
+ const plotHeight = height - padding.top - padding.bottom;
1482
+ const allValues = days.flatMap((day) => {
1483
+ const totals = getDailyTrendTotals(day, selectedModel);
1484
+ return visibleMetrics.map((metric) => totals[metric.key] || 0);
1485
+ });
1486
+ const maxValue = Math.max(1, ...allValues);
1487
+ const xForIndex = (index) =>
1488
+ days.length <= 1
1489
+ ? padding.left + plotWidth / 2
1490
+ : padding.left + (plotWidth * index) / (days.length - 1);
1491
+ const hitWidth =
1492
+ days.length <= 1 ? plotWidth : plotWidth / (days.length - 1);
1493
+ const yForValue = (value) =>
1494
+ padding.top
1495
+ + plotHeight
1496
+ - (Math.max(0, value) / maxValue) * plotHeight;
1497
+ const labelEvery = Math.max(1, Math.ceil(days.length / 6));
1498
+ const yTicks = [0, 0.25, 0.5, 0.75, 1].map((ratio) =>
1499
+ Math.round(maxValue * ratio)
1500
+ );
1501
+
1502
+ const grid = yTicks
1503
+ .map((tick) => {
1504
+ const y = yForValue(tick);
1505
+ return `
1506
+ <line x1="${padding.left}" x2="${
1507
+ width - padding.right
1508
+ }" y1="${y}" y2="${y}" stroke="var(--color-bg-light-2)" stroke-width="1" />
1509
+ <text x="${padding.left - 8}" y="${
1510
+ y + 4
1511
+ }" text-anchor="end" font-size="11" fill="var(--color-gray-accent)">${formatNumber(
1512
+ tick
1513
+ )}</text>
1514
+ `;
1515
+ })
1516
+ .join("");
1517
+ const xLabels = days
1518
+ .map((day, index) => {
1519
+ if (index % labelEvery !== 0 && index !== days.length - 1) {
1520
+ return "";
1521
+ }
1522
+ return `<text x="${xForIndex(index)}" y="${
1523
+ height - 10
1524
+ }" text-anchor="middle" font-size="11" fill="var(--color-gray-accent)">${escapeHtml(
1525
+ formatChartDate(day.date)
1526
+ )}</text>`;
1527
+ })
1528
+ .join("");
1529
+ const lines = visibleMetrics
1530
+ .map((metric) => {
1531
+ const points = days
1532
+ .map((day, index) => {
1533
+ const totals = getDailyTrendTotals(day, selectedModel);
1534
+ return `${xForIndex(index)},${yForValue(
1535
+ totals[metric.key] || 0
1536
+ )}`;
1537
+ })
1538
+ .join(" ");
1539
+ const circles = days
1540
+ .map((day, index) => {
1541
+ const totals = getDailyTrendTotals(day, selectedModel);
1542
+ const value = totals[metric.key] || 0;
1543
+ return `<circle cx="${xForIndex(index)}" cy="${yForValue(
1544
+ value
1545
+ )}" r="2.5" fill="${
1546
+ metric.color
1547
+ }" data-trend-day-index="${index}">
1548
+ <title>${escapeHtml(
1549
+ `${formatChartDate(day.date)} ${metric.label}: ${formatNumber(
1550
+ value
1551
+ )}`
1552
+ )}</title>
1553
+ </circle>`;
1554
+ })
1555
+ .join("");
1556
+ return `
1557
+ <polyline fill="none" points="${points}" stroke="${metric.color}" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.25">
1558
+ <title>${escapeHtml(metric.label)}</title>
1559
+ </polyline>
1560
+ ${circles}
1561
+ `;
1562
+ })
1563
+ .join("");
1564
+ const activeMarker = activeDay
1565
+ ? `<line x1="${xForIndex(activeIndex)}" x2="${xForIndex(
1566
+ activeIndex
1567
+ )}" y1="${padding.top}" y2="${
1568
+ padding.top + plotHeight
1569
+ }" stroke="var(--color-bg-light-4)" stroke-dasharray="4 4" stroke-width="1" />`
1570
+ : "";
1571
+ const hitRects = days
1572
+ .map((day, index) => {
1573
+ const x = xForIndex(index);
1574
+ const hitStart = Math.max(padding.left, x - hitWidth / 2);
1575
+ const hitEnd = Math.min(
1576
+ width - padding.right,
1577
+ x + hitWidth / 2
1578
+ );
1579
+ const totals = getDailyTrendTotals(day, selectedModel);
1580
+ const title = [
1581
+ formatChartDate(day.date),
1582
+ ...visibleMetrics.map(
1583
+ (metric) =>
1584
+ `${metric.label}: ${formatNumber(
1585
+ totals[metric.key] || 0
1586
+ )}`
1587
+ ),
1588
+ ].join("\n");
1589
+ return `<rect x="${hitStart}" y="${padding.top}" width="${
1590
+ hitEnd - hitStart
1591
+ }" height="${plotHeight}" fill="transparent" style="cursor: pointer;" data-trend-day-index="${index}">
1592
+ <title>${escapeHtml(title)}</title>
1593
+ </rect>`;
1594
+ })
1595
+ .join("");
1596
+ const legend = [
1597
+ ...metrics.map(
1598
+ (metric) => `
1599
+ <span class="inline-flex items-center gap-1.5 text-xs font-medium" style="color: var(--color-gray);">
1600
+ <span class="h-2 w-2 rounded-full" style="background-color: ${
1601
+ metric.color
1602
+ };"></span>
1603
+ ${escapeHtml(metric.label)}
1604
+ </span>
1605
+ `
1606
+ ),
1607
+ ].join("");
1608
+ const activeValues = activeDay
1609
+ ? `
1610
+ <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);">
1611
+ <span class="font-semibold" style="color: var(--color-fg-lightest);">${escapeHtml(
1612
+ formatChartDate(activeDay.date)
1613
+ )}</span>
1614
+ ${visibleMetrics
1615
+ .map(
1616
+ (metric) =>
1617
+ `<span>${escapeHtml(metric.label)}: ${formatNumber(
1618
+ activeTotals[metric.key] || 0
1619
+ )}</span>`
1620
+ )
1621
+ .join("")}
1622
+ </div>
1623
+ `
1624
+ : "";
1625
+
1626
+ return `
1627
+ <div class="p-4 ${state.isTokenUsageLoading ? "opacity-60" : ""}">
1628
+ <svg class="w-full h-56" viewBox="0 0 ${width} ${height}" role="img">
1629
+ ${grid}
1630
+ ${xLabels}
1631
+ ${activeMarker}
1632
+ ${lines}
1633
+ ${hitRects}
1634
+ </svg>
1635
+ <div class="mt-2 flex flex-wrap gap-5">
1636
+ ${legend}
1637
+ </div>
1638
+ ${activeValues}
1639
+ </div>
1640
+ `;
1641
+ }
1642
+
1643
+ function renderTokenUsageValueLines(value) {
1644
+ const lines = Array.isArray(value) ? value : [value];
1645
+ return lines
1646
+ .map((line) => `<span class="block whitespace-nowrap">${escapeHtml(line)}</span>`)
1647
+ .join("");
1648
+ }
1649
+
1650
+ function renderTokenUsageMetric(label, value, accentColor, fontSize) {
1651
+ const displayText = getMetricDisplayText(value);
1652
+ return `
1653
+ <div class="metric-card">
1654
+ <div class="metric-label">
1655
+ <span class="metric-dot" style="background-color: ${accentColor};"></span>
1656
+ <span>${escapeHtml(label)}</span>
1657
+ </div>
1658
+ <div class="metric-value" style="color: var(--color-fg-lightest);${
1659
+ fontSize ? ` font-size: ${fontSize};` : ""
1660
+ }">${renderTokenUsageValueLines(displayText)}</div>
1661
+ <div class="metric-note">Current period</div>
1662
+ </div>
1663
+ `;
1664
+ }
1665
+
1666
+ function renderTokenUsageCostMetric(label, value, accentColor, fontSize) {
1667
+ return `
1668
+ <div class="metric-card">
1669
+ <div class="metric-label">
1670
+ <span class="metric-dot" style="background-color: ${accentColor};"></span>
1671
+ <span>${escapeHtml(label)}</span>
1672
+ </div>
1673
+ <div class="metric-value" style="color: var(--color-fg-lightest);${
1674
+ fontSize ? ` font-size: ${fontSize};` : ""
1675
+ }">${renderTokenUsageValueLines(value)}</div>
1676
+ <div class="metric-note">Estimated total</div>
1677
+ </div>
1678
+ `;
1679
+ }
1680
+
1681
+ function renderTokenUsageModelBreakdown(summary) {
1682
+ if (!summary || summary.byModel.length === 0) {
1683
+ return renderEmptyState(
1684
+ "No token usage recorded for the selected period."
1685
+ );
1686
+ }
1687
+
1688
+ const rows = summary.byModel
1689
+ .map((model) => {
1690
+ return `
1691
+ <tr class="border-b last:border-b-0" style="border-color: var(--color-bg-light-1);">
1692
+ <td class="px-4 py-2 max-w-[280px] truncate" style="color: var(--color-fg-lightest);" title="${escapeHtml(
1693
+ model.model
1694
+ )}">${escapeHtml(model.model)}</td>
1695
+ <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
1696
+ model.request_count
1697
+ )}</td>
1698
+ <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
1699
+ model.input_tokens
1700
+ )}</td>
1701
+ <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
1702
+ model.output_tokens
1703
+ )}</td>
1704
+ <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
1705
+ model.cache_read_input_tokens
1706
+ )}</td>
1707
+ <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
1708
+ model.cache_creation_input_tokens
1709
+ )}</td>
1710
+ <td class="px-4 py-2 text-right font-semibold" style="color: var(--color-yellow-accent);">${formatNumber(
1711
+ model.total_tokens
1712
+ )}</td>
1713
+ <td class="px-4 py-2 text-right font-semibold" style="color: var(--color-yellow-accent);">
1714
+ <span class="inline-flex flex-col items-end leading-5">${renderTokenUsageValueLines(
1715
+ formatTokenUsageCosts(model.costs)
1716
+ )}</span>
1717
+ </td>
1718
+ </tr>
1719
+ `;
1720
+ })
1721
+ .join("");
1722
+
1723
+ return `
1724
+ <div class="overflow-auto ${state.isTokenUsageLoading ? "opacity-60" : ""}">
1725
+ <table class="data-table min-w-[860px] text-left">
1726
+ <thead>
1727
+ <tr>
1728
+ <th class="px-4 py-2 font-semibold">Model</th>
1729
+ <th class="px-4 py-2 text-right font-semibold">Requests</th>
1730
+ <th class="px-4 py-2 text-right font-semibold">Input</th>
1731
+ <th class="px-4 py-2 text-right font-semibold">Output</th>
1732
+ <th class="px-4 py-2 text-right font-semibold">Cache Read</th>
1733
+ <th class="px-4 py-2 text-right font-semibold">Cache Write</th>
1734
+ <th class="px-4 py-2 text-right font-semibold">Total Tokens</th>
1735
+ <th class="px-4 py-2 text-right font-semibold">Total Cost</th>
1736
+ </tr>
1737
+ </thead>
1738
+ <tbody>
1739
+ ${rows}
1740
+ </tbody>
1741
+ </table>
1742
+ </div>
1743
+ `;
1744
+ }
1745
+
1746
+ function renderTokenUsageEventsTable(eventsPage) {
1747
+ if (!eventsPage || eventsPage.items.length === 0) {
1748
+ return renderEmptyState(
1749
+ "No token usage detail rows for the selected period."
1750
+ );
1751
+ }
1752
+
1753
+ const rows = eventsPage.items
1754
+ .map((event) => {
1755
+ const userId = formatCellText(event.user_id);
1756
+ const sessionId = formatCellText(event.session_id);
1757
+ const traceId = formatCellText(event.trace_id);
1758
+
1759
+ return `
1760
+ <tr class="border-b last:border-b-0" style="border-color: var(--color-bg-light-1);">
1761
+ <td class="px-4 py-2 max-w-[170px] truncate whitespace-nowrap" style="color: var(--color-fg-dark);" title="${escapeHtml(
1762
+ event.created_at_utc
1763
+ )}">${escapeHtml(formatDateTime(event.created_at_ms))}</td>
1764
+ <td class="px-4 py-2 max-w-[100px] truncate" style="color: var(--color-fg-lightest);" title="${escapeHtml(
1765
+ userId
1766
+ )}">${escapeHtml(userId)}</td>
1767
+
1768
+ <td class="px-4 py-2 max-w-[140px] truncate" style="color: var(--color-fg-lightest);" title="${escapeHtml(
1769
+ event.model
1770
+ )}">${escapeHtml(event.model)}</td>
1771
+ <td class="px-4 py-2 max-w-[180px] truncate font-mono" style="color: var(--color-fg-dark);" title="${escapeHtml(
1772
+ sessionId
1773
+ )}">${escapeHtml(sessionId)}</td>
1774
+ <td class="px-4 py-2 max-w-[200px] truncate font-mono" style="color: var(--color-fg-dark);" title="${escapeHtml(
1775
+ traceId
1776
+ )}">${escapeHtml(traceId)}</td>
1777
+ <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
1778
+ event.input_tokens
1779
+ )}</td>
1780
+ <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
1781
+ event.output_tokens
1782
+ )}</td>
1783
+ <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
1784
+ event.cache_read_input_tokens
1785
+ )}</td>
1786
+ <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber(
1787
+ event.cache_creation_input_tokens
1788
+ )}</td>
1789
+ <td class="px-4 py-2 text-right font-semibold" style="color: var(--color-yellow-accent);">${formatNumber(
1790
+ event.total_tokens
1791
+ )}</td>
1792
+ <td class="px-4 py-2 text-right font-semibold" style="color: var(--color-yellow-accent);">
1793
+ <span class="inline-flex flex-col items-end leading-5">${renderTokenUsageValueLines(
1794
+ formatTokenUsageCost(event.cost)
1795
+ )}</span>
1796
+ </td>
1797
+ </tr>
1798
+ `;
1799
+ })
1800
+ .join("");
1801
+
1802
+ return `
1803
+ <div class="overflow-auto ${state.isEventsLoading ? "opacity-60" : ""}">
1804
+ <table class="data-table min-w-[1000px] text-left">
1805
+ <thead>
1806
+ <tr>
1807
+ <th class="px-4 py-2 font-semibold">Time</th>
1808
+ <th class="px-4 py-2 font-semibold">User</th>
1809
+
1810
+ <th class="px-4 py-2 font-semibold">Model</th>
1811
+ <th class="px-4 py-2 font-semibold">Session</th>
1812
+ <th class="px-4 py-2 font-semibold">Trace</th>
1813
+ <th class="px-4 py-2 text-right font-semibold">Input</th>
1814
+ <th class="px-4 py-2 text-right font-semibold">Output</th>
1815
+ <th class="px-4 py-2 text-right font-semibold">Cache Read</th>
1816
+ <th class="px-4 py-2 text-right font-semibold">Cache Write</th>
1817
+ <th class="px-4 py-2 text-right font-semibold">Total Tokens</th>
1818
+ <th class="px-4 py-2 text-right font-semibold">Total Cost</th>
1819
+ </tr>
1820
+ </thead>
1821
+ <tbody>
1822
+ ${rows}
1823
+ </tbody>
1824
+ </table>
1825
+ </div>
1826
+ `;
1827
+ }
1828
+
1829
+ function renderPaginationButton(action, label, disabled) {
1830
+ return `
1831
+ <button
1832
+ type="button"
1833
+ data-page-action="${action}"
1834
+ class="pill-button px-3 py-1.5 text-xs font-semibold"
1835
+ ${disabled ? "disabled" : ""}
1836
+ >
1837
+ ${label}
1838
+ </button>
1839
+ `;
1840
+ }
1841
+
1842
+ function renderEmptyState(message) {
1843
+ return `
1844
+ <div class="px-4 py-8 text-sm" style="color: var(--color-gray);">
1845
+ ${escapeHtml(message)}
1846
+ </div>
1847
+ `;
1848
+ }
1849
+
1850
+ /**
1851
+ * Renders a loading spinner.
1852
+ * @returns {string} HTML string for the spinner.
1853
+ */
1854
+ function renderSpinner() {
1855
+ return `
1856
+ <div class="flex justify-center items-center py-20">
1857
+ <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>
1858
+ </div>`;
1859
+ }
1860
+
1861
+ /**
1862
+ * Renders an error message box.
1863
+ * @param {string} message - The error message to display.
1864
+ * @returns {string} HTML string for the error message.
1865
+ */
1866
+ function renderError(message, title = "An Error Occurred") {
1867
+ return `
1868
+ <div
1869
+ class="p-3 border rounded-lg"
1870
+ style="background-color: rgba(220, 38, 38, 0.08); border-color: rgba(220, 38, 38, 0.22); color: var(--color-red);"
1871
+ role="alert"
1872
+ >
1873
+ <div class="flex items-start">
1874
+ <i data-lucide="alert-triangle" class="h-5 w-5 mr-3 mt-0.5"></i>
1875
+ <div>
1876
+ <p class="font-bold text-sm">${escapeHtml(title)}</p>
1877
+ <p class="text-xs">${escapeHtml(message)}</p>
1878
+ </div>
1879
+ </div>
1880
+ </div>
1881
+ `;
1882
+ }
1883
+
1884
+ /**
1885
+ * Renders a welcome message when the page first loads.
1886
+ * @returns {string} HTML string for the welcome message.
1887
+ */
1888
+ function renderWelcomeMessage() {
1889
+ return `
1890
+ <div class="panel text-center py-16 px-4">
1891
+ <i data-lucide="info" class="mx-auto h-10 w-10" style="color: var(--color-blue);"></i>
1892
+ <h3 class="mt-3 text-lg font-bold" style="color: var(--color-fg-lightest);">Usage data is ready to load</h3>
1893
+ <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>
1894
+ </div>
1895
+ `;
1896
+ }
1897
+
1898
+ // --- Data Fetching ---
1899
+
1900
+ /**
1901
+ * Fetches usage data and token usage data from the specified API endpoint.
1902
+ */
1903
+ async function fetchData(page = 1) {
1904
+ const endpoint = endpointUrlInput.value.trim();
1905
+ if (!endpoint) {
1906
+ state.data = null;
1907
+ state.tokenUsageDailySummary = null;
1908
+ state.tokenUsageSummary = null;
1909
+ state.tokenUsageEventsPage = null;
1910
+ state.error = "Endpoint URL cannot be empty.";
1911
+ state.tokenUsageDailyError =
1912
+ "Token usage requires a valid endpoint URL.";
1913
+ state.tokenUsageSummaryError =
1914
+ "Token usage requires a valid endpoint URL.";
1915
+ state.tokenUsageEventsError = null;
1916
+ render();
1917
+ return;
1918
+ }
1919
+
1920
+ const period = getSelectedPeriod();
1921
+ state.isLoading = true;
1922
+ state.error = null;
1923
+ state.tokenUsageDailyError = null;
1924
+ state.tokenUsageSummaryError = null;
1925
+ state.tokenUsageEventsError = null;
1926
+ render();
1927
+
1928
+ try {
1929
+ const usageUrl = resolveUsageUrl(endpoint);
1930
+ const [usageResult, summaryResult, dailyResult, eventsResult] =
1931
+ await Promise.allSettled([
1932
+ fetchJson(usageUrl),
1933
+ fetchJson(buildTokenUsageSummaryUrl(usageUrl, period)),
1934
+ fetchJson(buildTokenUsageDailyUrl(usageUrl, period)),
1935
+ fetchJson(buildTokenUsageEventsUrl(usageUrl, period, page)),
1936
+ ]);
1937
+
1938
+ if (usageResult.status === "fulfilled") {
1939
+ state.data = usageResult.value;
1940
+ } else {
1941
+ state.data = null;
1942
+ state.error = getErrorMessage(usageResult.reason);
1943
+ }
1944
+
1945
+ if (summaryResult.status === "fulfilled") {
1946
+ state.tokenUsageSummary = summaryResult.value;
1947
+ } else if (
1948
+ isMissingTokenUsageEndpointError(summaryResult.reason)
1949
+ ) {
1950
+ state.tokenUsageSummary = null;
1951
+ state.tokenUsageSummaryError = null;
1952
+ } else {
1953
+ state.tokenUsageSummary = null;
1954
+ state.tokenUsageSummaryError = getErrorMessage(
1955
+ summaryResult.reason
1956
+ );
1957
+ }
1958
+
1959
+ if (dailyResult.status === "fulfilled") {
1960
+ state.tokenUsageDailySummary = dailyResult.value;
1961
+ } else if (
1962
+ isMissingTokenUsageEndpointError(dailyResult.reason)
1963
+ ) {
1964
+ state.tokenUsageDailySummary = null;
1965
+ state.tokenUsageDailyError = null;
1966
+ } else {
1967
+ state.tokenUsageDailySummary = null;
1968
+ state.tokenUsageDailyError = getErrorMessage(
1969
+ dailyResult.reason
1970
+ );
1971
+ }
1972
+
1973
+ if (eventsResult.status === "fulfilled") {
1974
+ state.tokenUsageEventsPage = eventsResult.value;
1975
+ } else if (
1976
+ isMissingTokenUsageEndpointError(eventsResult.reason)
1977
+ ) {
1978
+ state.tokenUsageEventsPage = null;
1979
+ state.tokenUsageEventsError = null;
1980
+ } else {
1981
+ state.tokenUsageEventsPage = null;
1982
+ state.tokenUsageEventsError = getErrorMessage(
1983
+ eventsResult.reason
1984
+ );
1985
+ }
1986
+ } catch (error) {
1987
+ console.error("Fetch error:", error);
1988
+ state.data = null;
1989
+ state.tokenUsageDailySummary = null;
1990
+ state.tokenUsageSummary = null;
1991
+ state.tokenUsageEventsPage = null;
1992
+ state.error = getErrorMessage(error);
1993
+ state.tokenUsageDailyError = getErrorMessage(error);
1994
+ state.tokenUsageSummaryError = getErrorMessage(error);
1995
+ state.tokenUsageEventsError = getErrorMessage(error);
1996
+ } finally {
1997
+ state.isLoading = false;
1998
+ render();
1999
+ }
2000
+ }
2001
+
2002
+ async function fetchTokenUsageSummaryAndEvents(page = 1) {
2003
+ const endpoint = endpointUrlInput.value.trim();
2004
+ if (!endpoint) {
2005
+ state.tokenUsageDailySummary = null;
2006
+ state.tokenUsageSummary = null;
2007
+ state.tokenUsageEventsPage = null;
2008
+ state.tokenUsageDailyError =
2009
+ "Token usage requires a valid endpoint URL.";
2010
+ state.tokenUsageSummaryError =
2011
+ "Token usage requires a valid endpoint URL.";
2012
+ state.tokenUsageEventsError = null;
2013
+ render();
2014
+ return;
2015
+ }
2016
+
2017
+ const period = getSelectedPeriod();
2018
+ state.isTokenUsageLoading = true;
2019
+ state.tokenUsageDailySummary = null;
2020
+ state.tokenUsageSummary = null;
2021
+ state.tokenUsageEventsPage = null;
2022
+ state.tokenUsageDailyError = null;
2023
+ state.tokenUsageSummaryError = null;
2024
+ state.tokenUsageEventsError = null;
2025
+ render();
2026
+
2027
+ try {
2028
+ const usageUrl = resolveUsageUrl(endpoint);
2029
+ const [summaryResult, dailyResult, eventsResult] =
2030
+ await Promise.allSettled([
2031
+ fetchJson(buildTokenUsageSummaryUrl(usageUrl, period)),
2032
+ fetchJson(buildTokenUsageDailyUrl(usageUrl, period)),
2033
+ fetchJson(buildTokenUsageEventsUrl(usageUrl, period, page)),
2034
+ ]);
2035
+
2036
+ if (summaryResult.status === "fulfilled") {
2037
+ state.tokenUsageSummary = summaryResult.value;
2038
+ } else if (
2039
+ isMissingTokenUsageEndpointError(summaryResult.reason)
2040
+ ) {
2041
+ state.tokenUsageSummary = null;
2042
+ state.tokenUsageSummaryError = null;
2043
+ } else {
2044
+ state.tokenUsageSummary = null;
2045
+ state.tokenUsageSummaryError = getErrorMessage(
2046
+ summaryResult.reason
2047
+ );
2048
+ }
2049
+
2050
+ if (dailyResult.status === "fulfilled") {
2051
+ state.tokenUsageDailySummary = dailyResult.value;
2052
+ } else if (
2053
+ isMissingTokenUsageEndpointError(dailyResult.reason)
2054
+ ) {
2055
+ state.tokenUsageDailySummary = null;
2056
+ state.tokenUsageDailyError = null;
2057
+ } else {
2058
+ state.tokenUsageDailySummary = null;
2059
+ state.tokenUsageDailyError = getErrorMessage(
2060
+ dailyResult.reason
2061
+ );
2062
+ }
2063
+
2064
+ if (eventsResult.status === "fulfilled") {
2065
+ state.tokenUsageEventsPage = eventsResult.value;
2066
+ } else if (
2067
+ isMissingTokenUsageEndpointError(eventsResult.reason)
2068
+ ) {
2069
+ state.tokenUsageEventsPage = null;
2070
+ state.tokenUsageEventsError = null;
2071
+ } else {
2072
+ state.tokenUsageEventsPage = null;
2073
+ state.tokenUsageEventsError = getErrorMessage(
2074
+ eventsResult.reason
2075
+ );
2076
+ }
2077
+ } catch (error) {
2078
+ console.error("Token usage fetch error:", error);
2079
+ state.tokenUsageDailySummary = null;
2080
+ state.tokenUsageSummary = null;
2081
+ state.tokenUsageEventsPage = null;
2082
+ state.tokenUsageDailyError = getErrorMessage(error);
2083
+ state.tokenUsageSummaryError = getErrorMessage(error);
2084
+ state.tokenUsageEventsError = getErrorMessage(error);
2085
+ } finally {
2086
+ state.isTokenUsageLoading = false;
2087
+ render();
2088
+ }
2089
+ }
2090
+
2091
+ async function fetchTokenUsageEventsPage(page) {
2092
+ const endpoint = endpointUrlInput.value.trim();
2093
+ if (!endpoint) {
2094
+ state.tokenUsageEventsError =
2095
+ "Token usage requires a valid endpoint URL.";
2096
+ render();
2097
+ return;
2098
+ }
2099
+
2100
+ state.isEventsLoading = true;
2101
+ state.tokenUsageEventsError = null;
2102
+ render();
2103
+
2104
+ try {
2105
+ const usageUrl = resolveUsageUrl(endpoint);
2106
+ state.tokenUsageEventsPage = await fetchJson(
2107
+ buildTokenUsageEventsUrl(usageUrl, getSelectedPeriod(), page)
2108
+ );
2109
+ } catch (error) {
2110
+ if (isMissingTokenUsageEndpointError(error)) {
2111
+ state.tokenUsageEventsPage = null;
2112
+ state.tokenUsageEventsError = null;
2113
+ } else {
2114
+ console.error("Token usage events fetch error:", error);
2115
+ state.tokenUsageEventsError = getErrorMessage(error);
2116
+ }
2117
+ } finally {
2118
+ state.isEventsLoading = false;
2119
+ render();
2120
+ }
2121
+ }
2122
+
2123
+ // --- Event Handlers & Initialization ---
2124
+
2125
+ /**
2126
+ * Handles the form submission to trigger a data fetch.
2127
+ * @param {Event} event - The form submission event.
2128
+ */
2129
+ function handleFormSubmit(event) {
2130
+ event.preventDefault();
2131
+ syncEndpointCredential();
2132
+ storeApiKey(apiKeyInput.value, endpointUrlInput.value.trim());
2133
+ syncUrlState();
2134
+ void fetchData();
2135
+ }
2136
+
2137
+ function handlePeriodChange() {
2138
+ syncUrlState();
2139
+ void fetchTokenUsageSummaryAndEvents(1);
2140
+ }
2141
+
2142
+ function handleContentAreaClick(event) {
2143
+ if (!(event.target instanceof Element)) {
2144
+ return;
2145
+ }
2146
+
2147
+ const trendMetric = event.target.closest("[data-trend-metric]");
2148
+ if (trendMetric) {
2149
+ state.tokenUsageTrendMetric =
2150
+ trendMetric.getAttribute("data-trend-metric")
2151
+ || ALL_METRICS_VALUE;
2152
+ render();
2153
+ return;
2154
+ }
2155
+
2156
+ const trendPoint = event.target.closest("[data-trend-day-index]");
2157
+ if (trendPoint) {
2158
+ const index = Number.parseInt(
2159
+ trendPoint.getAttribute("data-trend-day-index") || "",
2160
+ 10
2161
+ );
2162
+ if (Number.isFinite(index) && index >= 0) {
2163
+ state.tokenUsageTrendDayIndex = index;
2164
+ render();
2165
+ }
2166
+ return;
2167
+ }
2168
+
2169
+ const actionButton = event.target.closest("[data-page-action]");
2170
+ if (!actionButton || !state.tokenUsageEventsPage || state.isEventsLoading) {
2171
+ return;
2172
+ }
2173
+
2174
+ const action = actionButton.getAttribute("data-page-action");
2175
+ if (
2176
+ action === "previous"
2177
+ && state.tokenUsageEventsPage.page > 1
2178
+ ) {
2179
+ void fetchTokenUsageEventsPage(state.tokenUsageEventsPage.page - 1);
2180
+ }
2181
+
2182
+ if (
2183
+ action === "next"
2184
+ && state.tokenUsageEventsPage.page
2185
+ < state.tokenUsageEventsPage.total_pages
2186
+ ) {
2187
+ void fetchTokenUsageEventsPage(state.tokenUsageEventsPage.page + 1);
2188
+ }
2189
+ }
2190
+
2191
+ function handleContentAreaChange(event) {
2192
+ if (!(event.target instanceof HTMLSelectElement)) {
2193
+ return;
2194
+ }
2195
+
2196
+ if (!event.target.matches("[data-token-usage-trend-model]")) {
2197
+ return;
2198
+ }
2199
+
2200
+ state.tokenUsageTrendModel =
2201
+ event.target.value || ALL_MODELS_VALUE;
2202
+ render();
2203
+ }
2204
+
2205
+ let currentEndpointOrigin = "";
2206
+
2207
+ function syncEndpointCredential() {
2208
+ const endpoint = endpointUrlInput.value.trim();
2209
+ const endpointOrigin = getEndpointOrigin(endpoint);
2210
+ if (endpointOrigin === currentEndpointOrigin) {
2211
+ return;
2212
+ }
2213
+ apiKeyInput.value = loadStoredApiKey(endpoint);
2214
+ currentEndpointOrigin = endpointOrigin;
2215
+ }
2216
+
2217
+ /**
2218
+ * Initializes the application.
2219
+ */
2220
+ function init() {
2221
+ endpointForm.addEventListener("submit", handleFormSubmit);
2222
+ tokenUsagePeriodSelect.addEventListener("change", handlePeriodChange);
2223
+ apiKeyInput.addEventListener("input", () => {
2224
+ storeApiKey(
2225
+ apiKeyInput.value,
2226
+ endpointUrlInput.value.trim()
2227
+ );
2228
+ });
2229
+ endpointUrlInput.addEventListener("change", syncEndpointCredential);
2230
+ contentArea.addEventListener("click", handleContentAreaClick);
2231
+ contentArea.addEventListener("change", handleContentAreaChange);
2232
+
2233
+ const urlParams = new URLSearchParams(window.location.search);
2234
+ const periodFromUrl = normalizePeriod(urlParams.get("period"));
2235
+ const endpointFromUrl = urlParams.get("endpoint");
2236
+ const initialEndpoint = endpointFromUrl || DEFAULT_ENDPOINT;
2237
+
2238
+ state.tokenUsagePeriod = periodFromUrl;
2239
+ tokenUsagePeriodSelect.value = periodFromUrl;
2240
+ endpointUrlInput.value = initialEndpoint;
2241
+ migrateLegacyApiKey(initialEndpoint);
2242
+ syncEndpointCredential();
2243
+
2244
+ if (endpointFromUrl) {
2245
+ void fetchData();
2246
+ } else {
2247
+ render();
2248
+ }
2249
+ }
2250
+
2251
+ // Start the app
2252
+ init();
2253
+ });
2254
+ </script>
2255
+
2256
+ </body>
2257
+ </html>