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.
- package/LICENSE +21 -0
- package/README.md +888 -0
- package/README.zh-CN.md +940 -0
- package/dist/auth-BiVetBYt.js +446 -0
- package/dist/auth-Bu4MXadr.js +2 -0
- package/dist/config-SytZjLq8.js +544 -0
- package/dist/debug-BFadhEB4.js +90 -0
- package/dist/electron-fetch-BRX-ug5E.js +20 -0
- package/dist/fast-path-BoMnZCVC.js +9 -0
- package/dist/main.js +49 -0
- package/dist/mcp-fpSlKZxK.js +14 -0
- package/dist/mcp-server-BeNu_Edl.js +25 -0
- package/dist/mcp-server-DQ4r-fAy.js +2 -0
- package/dist/models-YMUf33c-.js +88 -0
- package/dist/server-CFQmvoAJ.js +11710 -0
- package/dist/start-FFVCi8su.js +528 -0
- package/dist/tls-Aq1Dd8E2.js +14 -0
- package/dist/token-D9svRIYW.js +1950 -0
- package/dist/tool-search-Ds1vbmGG.js +114 -0
- package/package.json +96 -0
- package/pages/index.html +2257 -0
package/pages/index.html
ADDED
|
@@ -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("&", "&")
|
|
517
|
+
.replaceAll("<", "<")
|
|
518
|
+
.replaceAll(">", ">")
|
|
519
|
+
.replaceAll('"', """)
|
|
520
|
+
.replaceAll("'", "'");
|
|
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>
|