copilot-cursor-proxy 1.0.0 → 1.0.2
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/README.md +134 -151
- package/auth-config.ts +61 -0
- package/dashboard.html +814 -270
- package/package.json +2 -2
- package/proxy-router.ts +213 -4
- package/responses-converters.ts +3 -3
- package/stream-proxy.ts +32 -1
- package/usage-db.ts +210 -0
package/dashboard.html
CHANGED
|
@@ -1,299 +1,843 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Copilot Cursor Proxy — Dashboard</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #0a0a0a;
|
|
10
|
+
--bg-card: #141414;
|
|
11
|
+
--bg-hover: #1a1a1a;
|
|
12
|
+
--border: #222;
|
|
13
|
+
--border-light: #333;
|
|
14
|
+
--text: #e0e0e0;
|
|
15
|
+
--text-dim: #777;
|
|
16
|
+
--text-muted: #555;
|
|
17
|
+
--accent: #58a6ff;
|
|
18
|
+
--green: #3fb950;
|
|
19
|
+
--red: #f85149;
|
|
20
|
+
--yellow: #d29922;
|
|
21
|
+
--blue: #58a6ff;
|
|
22
|
+
--purple: #bc8cff;
|
|
23
|
+
--orange: #f0883e;
|
|
24
|
+
--radius: 8px;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
28
|
+
|
|
29
|
+
body {
|
|
30
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
31
|
+
background: var(--bg);
|
|
32
|
+
color: var(--text);
|
|
33
|
+
min-height: 100vh;
|
|
34
|
+
display: flex;
|
|
35
|
+
flex-direction: column;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* ── Header ─────────────────────────────────────── */
|
|
39
|
+
.header {
|
|
40
|
+
display: flex;
|
|
41
|
+
align-items: center;
|
|
42
|
+
justify-content: space-between;
|
|
43
|
+
padding: 16px 24px;
|
|
44
|
+
border-bottom: 1px solid var(--border);
|
|
45
|
+
background: var(--bg);
|
|
46
|
+
}
|
|
47
|
+
.header-title {
|
|
48
|
+
font-size: 15px;
|
|
49
|
+
font-weight: 600;
|
|
50
|
+
display: flex;
|
|
51
|
+
align-items: center;
|
|
52
|
+
gap: 8px;
|
|
53
|
+
}
|
|
54
|
+
.header-title .dot {
|
|
55
|
+
width: 8px; height: 8px;
|
|
56
|
+
border-radius: 50%;
|
|
57
|
+
background: var(--green);
|
|
58
|
+
display: inline-block;
|
|
59
|
+
}
|
|
60
|
+
.header-badge {
|
|
61
|
+
font-size: 11px;
|
|
62
|
+
padding: 2px 8px;
|
|
63
|
+
border-radius: 10px;
|
|
64
|
+
background: #1a2332;
|
|
65
|
+
color: var(--accent);
|
|
66
|
+
border: 1px solid #1a3050;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ── Tabs ────────────────────────────────────────── */
|
|
70
|
+
.tabs {
|
|
71
|
+
display: flex;
|
|
72
|
+
gap: 0;
|
|
73
|
+
border-bottom: 1px solid var(--border);
|
|
74
|
+
background: var(--bg);
|
|
75
|
+
padding: 0 24px;
|
|
76
|
+
}
|
|
77
|
+
.tab {
|
|
78
|
+
padding: 12px 20px;
|
|
79
|
+
font-size: 13px;
|
|
80
|
+
color: var(--text-dim);
|
|
81
|
+
cursor: pointer;
|
|
82
|
+
border-bottom: 2px solid transparent;
|
|
83
|
+
transition: all .15s;
|
|
84
|
+
user-select: none;
|
|
85
|
+
}
|
|
86
|
+
.tab:hover { color: var(--text); }
|
|
87
|
+
.tab.active {
|
|
88
|
+
color: var(--text);
|
|
89
|
+
border-bottom-color: var(--accent);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ── Content ─────────────────────────────────────── */
|
|
93
|
+
.content {
|
|
94
|
+
flex: 1;
|
|
95
|
+
padding: 24px;
|
|
96
|
+
overflow-y: auto;
|
|
97
|
+
}
|
|
98
|
+
.tab-panel { display: none; }
|
|
99
|
+
.tab-panel.active { display: block; }
|
|
100
|
+
|
|
101
|
+
/* ── Cards ───────────────────────────────────────── */
|
|
102
|
+
.cards {
|
|
103
|
+
display: grid;
|
|
104
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
105
|
+
gap: 12px;
|
|
106
|
+
margin-bottom: 24px;
|
|
107
|
+
}
|
|
108
|
+
.card {
|
|
109
|
+
background: var(--bg-card);
|
|
110
|
+
border: 1px solid var(--border);
|
|
111
|
+
border-radius: var(--radius);
|
|
112
|
+
padding: 16px;
|
|
113
|
+
}
|
|
114
|
+
.card-label {
|
|
115
|
+
font-size: 11px;
|
|
116
|
+
text-transform: uppercase;
|
|
117
|
+
letter-spacing: .06em;
|
|
118
|
+
color: var(--text-dim);
|
|
119
|
+
margin-bottom: 6px;
|
|
120
|
+
}
|
|
121
|
+
.card-value {
|
|
122
|
+
font-size: 22px;
|
|
123
|
+
font-weight: 600;
|
|
124
|
+
}
|
|
125
|
+
.card-value.mono {
|
|
126
|
+
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
|
|
127
|
+
font-size: 14px;
|
|
128
|
+
color: var(--accent);
|
|
129
|
+
word-break: break-all;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ── Copyable row ────────────────────────────────── */
|
|
133
|
+
.copy-row {
|
|
134
|
+
display: flex;
|
|
135
|
+
align-items: center;
|
|
136
|
+
gap: 8px;
|
|
137
|
+
}
|
|
138
|
+
.copy-btn {
|
|
139
|
+
background: transparent;
|
|
140
|
+
border: 1px solid var(--border-light);
|
|
141
|
+
color: var(--text-dim);
|
|
142
|
+
cursor: pointer;
|
|
143
|
+
padding: 4px 10px;
|
|
144
|
+
font-size: 11px;
|
|
145
|
+
border-radius: 4px;
|
|
146
|
+
transition: all .15s;
|
|
147
|
+
flex-shrink: 0;
|
|
148
|
+
}
|
|
149
|
+
.copy-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
150
|
+
.copy-btn.copied { border-color: var(--green); color: var(--green); }
|
|
151
|
+
|
|
152
|
+
/* ── Tables ──────────────────────────────────────── */
|
|
153
|
+
.table-wrap {
|
|
154
|
+
background: var(--bg-card);
|
|
155
|
+
border: 1px solid var(--border);
|
|
156
|
+
border-radius: var(--radius);
|
|
157
|
+
overflow: hidden;
|
|
158
|
+
margin-bottom: 24px;
|
|
159
|
+
}
|
|
160
|
+
.table-header {
|
|
161
|
+
padding: 12px 16px;
|
|
162
|
+
font-size: 13px;
|
|
163
|
+
font-weight: 600;
|
|
164
|
+
border-bottom: 1px solid var(--border);
|
|
165
|
+
display: flex;
|
|
166
|
+
align-items: center;
|
|
167
|
+
justify-content: space-between;
|
|
168
|
+
}
|
|
169
|
+
.table-header .badge {
|
|
170
|
+
font-size: 11px;
|
|
171
|
+
padding: 2px 8px;
|
|
172
|
+
border-radius: 10px;
|
|
173
|
+
background: #1a2332;
|
|
174
|
+
color: var(--accent);
|
|
175
|
+
}
|
|
176
|
+
table {
|
|
177
|
+
width: 100%;
|
|
178
|
+
border-collapse: collapse;
|
|
179
|
+
font-size: 13px;
|
|
180
|
+
}
|
|
181
|
+
th {
|
|
182
|
+
text-align: left;
|
|
183
|
+
padding: 10px 16px;
|
|
184
|
+
font-size: 11px;
|
|
185
|
+
text-transform: uppercase;
|
|
186
|
+
letter-spacing: .05em;
|
|
187
|
+
color: var(--text-dim);
|
|
188
|
+
border-bottom: 1px solid var(--border);
|
|
189
|
+
font-weight: 500;
|
|
190
|
+
cursor: default;
|
|
191
|
+
}
|
|
192
|
+
td {
|
|
193
|
+
padding: 10px 16px;
|
|
194
|
+
border-bottom: 1px solid #1a1a1a;
|
|
195
|
+
vertical-align: middle;
|
|
196
|
+
}
|
|
197
|
+
tr:hover td { background: var(--bg-hover); }
|
|
198
|
+
.status-dot {
|
|
199
|
+
width: 8px; height: 8px;
|
|
200
|
+
border-radius: 50%;
|
|
201
|
+
display: inline-block;
|
|
202
|
+
margin-right: 6px;
|
|
203
|
+
}
|
|
204
|
+
.status-dot.ok { background: var(--green); }
|
|
205
|
+
.status-dot.err { background: var(--red); }
|
|
206
|
+
.model-badge {
|
|
207
|
+
font-family: 'SF Mono', 'Consolas', monospace;
|
|
208
|
+
font-size: 12px;
|
|
209
|
+
padding: 2px 8px;
|
|
210
|
+
border-radius: 4px;
|
|
211
|
+
background: #1a1a2e;
|
|
212
|
+
color: var(--purple);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* ── Section titles ──────────────────────────────── */
|
|
216
|
+
.section-title {
|
|
217
|
+
font-size: 13px;
|
|
218
|
+
font-weight: 600;
|
|
219
|
+
margin-bottom: 12px;
|
|
220
|
+
color: var(--text-dim);
|
|
221
|
+
text-transform: uppercase;
|
|
222
|
+
letter-spacing: .05em;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* ── Console ─────────────────────────────────────── */
|
|
226
|
+
.console-wrap {
|
|
227
|
+
background: #000;
|
|
228
|
+
border: 1px solid var(--border);
|
|
229
|
+
border-radius: var(--radius);
|
|
230
|
+
display: flex;
|
|
231
|
+
flex-direction: column;
|
|
232
|
+
height: calc(100vh - 160px);
|
|
233
|
+
min-height: 400px;
|
|
234
|
+
}
|
|
235
|
+
.console-toolbar {
|
|
236
|
+
display: flex;
|
|
237
|
+
align-items: center;
|
|
238
|
+
justify-content: space-between;
|
|
239
|
+
padding: 8px 16px;
|
|
240
|
+
border-bottom: 1px solid var(--border);
|
|
241
|
+
background: #0a0a0a;
|
|
242
|
+
}
|
|
243
|
+
.console-toolbar .title { font-size: 12px; color: var(--text-dim); }
|
|
244
|
+
.console-toolbar button {
|
|
245
|
+
background: transparent;
|
|
246
|
+
border: 1px solid var(--border-light);
|
|
247
|
+
color: var(--text-dim);
|
|
248
|
+
padding: 4px 12px;
|
|
249
|
+
font-size: 11px;
|
|
250
|
+
border-radius: 4px;
|
|
251
|
+
cursor: pointer;
|
|
252
|
+
transition: all .15s;
|
|
253
|
+
}
|
|
254
|
+
.console-toolbar button:hover { border-color: var(--red); color: var(--red); }
|
|
255
|
+
.console-output {
|
|
256
|
+
flex: 1;
|
|
257
|
+
overflow-y: auto;
|
|
258
|
+
padding: 12px 16px;
|
|
259
|
+
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
|
|
260
|
+
font-size: 12px;
|
|
261
|
+
line-height: 1.6;
|
|
262
|
+
white-space: pre-wrap;
|
|
263
|
+
word-break: break-all;
|
|
264
|
+
}
|
|
265
|
+
.log-line { display: block; }
|
|
266
|
+
.log-line .ts { color: #555; margin-right: 8px; }
|
|
267
|
+
.log-line.LOG .lvl { color: var(--green); }
|
|
268
|
+
.log-line.INFO .lvl { color: var(--blue); }
|
|
269
|
+
.log-line.WARN .lvl { color: var(--yellow); }
|
|
270
|
+
.log-line.ERROR .lvl { color: var(--red); }
|
|
271
|
+
.log-line.DEBUG .lvl { color: var(--purple); }
|
|
272
|
+
.log-line .msg { color: #ccc; }
|
|
273
|
+
.log-line.ERROR .msg { color: #f88; }
|
|
274
|
+
.log-line.WARN .msg { color: #dda; }
|
|
275
|
+
|
|
276
|
+
/* ── Responsive ──────────────────────────────────── */
|
|
277
|
+
@media (max-width: 640px) {
|
|
278
|
+
.cards { grid-template-columns: 1fr 1fr; }
|
|
279
|
+
.content { padding: 16px; }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/* ── Misc ────────────────────────────────────────── */
|
|
283
|
+
.empty-state {
|
|
284
|
+
text-align: center;
|
|
285
|
+
padding: 48px 16px;
|
|
286
|
+
color: var(--text-muted);
|
|
287
|
+
font-size: 13px;
|
|
288
|
+
}
|
|
289
|
+
.num { font-variant-numeric: tabular-nums; }
|
|
290
|
+
|
|
291
|
+
/* ── Persistence bar ─────────────────────────────── */
|
|
292
|
+
.persistence-bar {
|
|
293
|
+
display: flex;
|
|
294
|
+
align-items: center;
|
|
295
|
+
gap: 8px;
|
|
296
|
+
padding: 8px 14px;
|
|
297
|
+
margin-bottom: 20px;
|
|
298
|
+
background: #0d1a0d;
|
|
299
|
+
border: 1px solid #1a3a1a;
|
|
300
|
+
border-radius: var(--radius);
|
|
301
|
+
font-size: 12px;
|
|
302
|
+
color: var(--text-dim);
|
|
303
|
+
}
|
|
304
|
+
.persist-dot {
|
|
305
|
+
width: 6px; height: 6px;
|
|
306
|
+
border-radius: 50%;
|
|
307
|
+
background: var(--green);
|
|
308
|
+
flex-shrink: 0;
|
|
309
|
+
}
|
|
310
|
+
/* ── Toggle switch ──────────────────────────────── */
|
|
311
|
+
.toggle {
|
|
312
|
+
position: relative;
|
|
313
|
+
width: 44px;
|
|
314
|
+
height: 24px;
|
|
315
|
+
display: inline-block;
|
|
316
|
+
}
|
|
317
|
+
.toggle input { opacity: 0; width: 0; height: 0; }
|
|
318
|
+
.toggle-slider {
|
|
319
|
+
position: absolute;
|
|
320
|
+
inset: 0;
|
|
321
|
+
background: #333;
|
|
322
|
+
border-radius: 24px;
|
|
323
|
+
cursor: pointer;
|
|
324
|
+
transition: 0.3s;
|
|
325
|
+
}
|
|
326
|
+
.toggle-slider::before {
|
|
327
|
+
content: '';
|
|
328
|
+
position: absolute;
|
|
329
|
+
width: 18px;
|
|
330
|
+
height: 18px;
|
|
331
|
+
left: 3px;
|
|
332
|
+
bottom: 3px;
|
|
333
|
+
background: #fff;
|
|
334
|
+
border-radius: 50%;
|
|
335
|
+
transition: 0.3s;
|
|
336
|
+
}
|
|
337
|
+
.toggle input:checked + .toggle-slider {
|
|
338
|
+
background: #22c55e;
|
|
339
|
+
}
|
|
340
|
+
.toggle input:checked + .toggle-slider::before {
|
|
341
|
+
transform: translateX(20px);
|
|
342
|
+
}
|
|
343
|
+
</style>
|
|
152
344
|
</head>
|
|
153
345
|
<body>
|
|
154
346
|
|
|
155
|
-
|
|
347
|
+
<!-- Header -->
|
|
348
|
+
<div class="header">
|
|
349
|
+
<div class="header-title">
|
|
350
|
+
<span class="dot"></span>
|
|
351
|
+
Copilot Cursor Proxy
|
|
352
|
+
</div>
|
|
353
|
+
<span class="header-badge">localhost:4142</span>
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
<!-- Tabs -->
|
|
357
|
+
<div class="tabs">
|
|
358
|
+
<div class="tab active" data-tab="endpoint">Endpoint</div>
|
|
359
|
+
<div class="tab" data-tab="usage">Usage</div>
|
|
360
|
+
<div class="tab" data-tab="console">Console Log</div>
|
|
361
|
+
</div>
|
|
362
|
+
|
|
363
|
+
<!-- Content -->
|
|
364
|
+
<div class="content">
|
|
365
|
+
|
|
366
|
+
<!-- ═══════════ TAB 1: Endpoint ═══════════ -->
|
|
367
|
+
<div id="tab-endpoint" class="tab-panel active">
|
|
368
|
+
<div class="cards">
|
|
369
|
+
<div class="card">
|
|
370
|
+
<div class="card-label">Local Endpoint</div>
|
|
371
|
+
<div class="copy-row">
|
|
372
|
+
<div class="card-value mono" id="endpoint-url">http://localhost:4142/v1</div>
|
|
373
|
+
<button class="copy-btn" onclick="copyText('http://localhost:4142/v1',this)">Copy</button>
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
<div class="card">
|
|
377
|
+
<div class="card-label">API Key</div>
|
|
378
|
+
<div class="copy-row">
|
|
379
|
+
<div class="card-value mono">dummy</div>
|
|
380
|
+
<button class="copy-btn" onclick="copyText('dummy',this)">Copy</button>
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
<div class="card" style="grid-column: 1 / -1">
|
|
384
|
+
<div class="card-label">Tunnel</div>
|
|
385
|
+
<div class="card-value" style="font-size:13px; color:var(--text-dim);">
|
|
386
|
+
Configure your HTTPS tunnel separately (e.g. ngrok, cloudflared)
|
|
387
|
+
</div>
|
|
388
|
+
</div>
|
|
389
|
+
</div>
|
|
390
|
+
|
|
391
|
+
<!-- API Key Management -->
|
|
392
|
+
<div class="card" style="margin-bottom: 24px;">
|
|
393
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
|
394
|
+
<h3 style="margin: 0; border: none; padding: 0;">API Key Protection</h3>
|
|
395
|
+
<label class="toggle">
|
|
396
|
+
<input type="checkbox" id="requireKeyToggle" onchange="toggleRequireKey(this.checked)">
|
|
397
|
+
<span class="toggle-slider"></span>
|
|
398
|
+
</label>
|
|
399
|
+
</div>
|
|
400
|
+
<p id="authStatus" style="color: #888; font-size: 13px; margin-bottom: 16px;">Loading...</p>
|
|
401
|
+
|
|
402
|
+
<div id="keysSection" style="display: none;">
|
|
403
|
+
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
|
404
|
+
<input type="text" id="keyNameInput" placeholder="Key name (e.g. Cursor)"
|
|
405
|
+
style="flex:1; background: #1a1a1a; border: 1px solid #333; color: #fff; padding: 8px 12px; border-radius: 6px; font-size: 13px;">
|
|
406
|
+
<button onclick="createKey()" style="background: #22c55e; color: #000; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-weight: 600; white-space: nowrap;">+ Create Key</button>
|
|
407
|
+
</div>
|
|
408
|
+
|
|
409
|
+
<!-- Newly created key (shown once) -->
|
|
410
|
+
<div id="newKeyAlert" style="display: none; background: #1a2e1a; border: 1px solid #22c55e; border-radius: 8px; padding: 12px; margin-bottom: 16px;">
|
|
411
|
+
<div style="color: #22c55e; font-weight: 600; margin-bottom: 4px;">⚠️ Copy this key now — it won't be shown again!</div>
|
|
412
|
+
<div style="display: flex; align-items: center; gap: 8px;">
|
|
413
|
+
<code id="newKeyValue" style="flex: 1; background: #111; padding: 8px; border-radius: 4px; font-size: 13px; color: #22c55e; word-break: break-all;"></code>
|
|
414
|
+
<button onclick="copyNewKey()" style="background: #333; border: none; color: #fff; padding: 6px 12px; border-radius: 4px; cursor: pointer;">Copy</button>
|
|
415
|
+
</div>
|
|
416
|
+
</div>
|
|
417
|
+
|
|
418
|
+
<!-- Keys list -->
|
|
419
|
+
<div id="keysList"></div>
|
|
420
|
+
</div>
|
|
421
|
+
</div>
|
|
156
422
|
|
|
157
|
-
|
|
158
|
-
|
|
423
|
+
<div class="section-title">Available Models</div>
|
|
424
|
+
<div class="table-wrap">
|
|
425
|
+
<div class="table-header">
|
|
426
|
+
Models <span class="badge" id="model-count">—</span>
|
|
427
|
+
</div>
|
|
428
|
+
<div id="models-loading" class="empty-state">Loading models…</div>
|
|
429
|
+
<table id="models-table" style="display:none">
|
|
430
|
+
<thead>
|
|
431
|
+
<tr>
|
|
432
|
+
<th>Model ID (use this)</th>
|
|
433
|
+
<th>Original ID</th>
|
|
434
|
+
<th>Display Name</th>
|
|
435
|
+
<th></th>
|
|
436
|
+
</tr>
|
|
437
|
+
</thead>
|
|
438
|
+
<tbody id="models-body"></tbody>
|
|
439
|
+
</table>
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
159
442
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
<
|
|
163
|
-
<div class="usage-grid">
|
|
443
|
+
<!-- ═══════════ TAB 2: Usage ═══════════ -->
|
|
444
|
+
<div id="tab-usage" class="tab-panel">
|
|
445
|
+
<div class="cards" id="usage-cards">
|
|
164
446
|
<div class="card">
|
|
165
|
-
<div class="label">
|
|
166
|
-
<div class="value" id="
|
|
447
|
+
<div class="card-label">Total Requests (Lifetime)</div>
|
|
448
|
+
<div class="card-value num" id="stat-requests">0</div>
|
|
167
449
|
</div>
|
|
168
450
|
<div class="card">
|
|
169
|
-
<div class="label">
|
|
170
|
-
<div class="value" id="
|
|
451
|
+
<div class="card-label">Prompt Tokens</div>
|
|
452
|
+
<div class="card-value num" id="stat-prompt">0</div>
|
|
171
453
|
</div>
|
|
172
454
|
<div class="card">
|
|
173
|
-
<div class="label">
|
|
174
|
-
<div class="value" id="
|
|
455
|
+
<div class="card-label">Completion Tokens</div>
|
|
456
|
+
<div class="card-value num" id="stat-completion">0</div>
|
|
175
457
|
</div>
|
|
176
458
|
<div class="card">
|
|
177
|
-
<div class="label">
|
|
178
|
-
<div class="value" id="
|
|
459
|
+
<div class="card-label">Total Tokens</div>
|
|
460
|
+
<div class="card-value num" id="stat-total">0</div>
|
|
179
461
|
</div>
|
|
180
462
|
</div>
|
|
181
463
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
</
|
|
187
|
-
|
|
188
|
-
<table id="models-table">
|
|
189
|
-
<thead>
|
|
190
|
-
<tr>
|
|
191
|
-
<th>Model Name</th>
|
|
192
|
-
<th>Original ID</th>
|
|
193
|
-
<th>Cursor Model ID (Use This)</th>
|
|
194
|
-
</tr>
|
|
195
|
-
</thead>
|
|
196
|
-
<tbody id="models-list">
|
|
197
|
-
<!-- Rows injected here -->
|
|
198
|
-
</tbody>
|
|
199
|
-
</table>
|
|
200
|
-
</div>
|
|
201
|
-
|
|
202
|
-
<script>
|
|
203
|
-
// Since dashboard is served by the proxy server itself, use relative paths!
|
|
204
|
-
const API_BASE = '';
|
|
205
|
-
|
|
206
|
-
async function fetchData() {
|
|
207
|
-
try {
|
|
208
|
-
// Fetch Usage (Proxy handles forwarding)
|
|
209
|
-
const usageRes = await fetch(`${API_BASE}/usage`);
|
|
210
|
-
if (!usageRes.ok) throw new Error('Failed to fetch usage');
|
|
211
|
-
const usageData = await usageRes.json();
|
|
212
|
-
|
|
213
|
-
// Fetch Models (Proxy handles modification)
|
|
214
|
-
const modelsRes = await fetch(`${API_BASE}/v1/models`);
|
|
215
|
-
if (!modelsRes.ok) throw new Error('Failed to fetch models');
|
|
216
|
-
const modelsData = await modelsRes.json();
|
|
217
|
-
|
|
218
|
-
render(usageData, modelsData);
|
|
219
|
-
} catch (err) {
|
|
220
|
-
showError(err.message);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function render(usage, models) {
|
|
225
|
-
document.getElementById('loading').style.display = 'none';
|
|
226
|
-
document.getElementById('content').style.display = 'block';
|
|
227
|
-
|
|
228
|
-
// 1. Render Usage
|
|
229
|
-
document.getElementById('user-login').textContent = usage.login || 'Unknown';
|
|
230
|
-
document.getElementById('plan-type').textContent = (usage.copilot_plan || 'free').toUpperCase();
|
|
231
|
-
|
|
232
|
-
// Check quota status
|
|
233
|
-
const chatSnapshot = usage.quota_snapshots?.chat;
|
|
234
|
-
if (chatSnapshot?.unlimited) {
|
|
235
|
-
document.getElementById('chat-quota').textContent = 'UNLIMITED';
|
|
236
|
-
} else {
|
|
237
|
-
document.getElementById('chat-quota').textContent = chatSnapshot?.remaining ?? '0';
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Date
|
|
241
|
-
const date = new Date(usage.quota_reset_date_utc || Date.now());
|
|
242
|
-
document.getElementById('reset-date').textContent = date.toLocaleDateString();
|
|
464
|
+
<div class="persistence-bar" id="persistence-bar" style="display:none">
|
|
465
|
+
<span class="persist-dot"></span>
|
|
466
|
+
<span id="persist-info">Data persisted</span>
|
|
467
|
+
<button class="copy-btn" onclick="flushUsage()" id="flush-btn" style="margin-left:auto">Save Now</button>
|
|
468
|
+
</div>
|
|
243
469
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
470
|
+
<div class="section-title">Daily History</div>
|
|
471
|
+
<div class="table-wrap" id="daily-wrap" style="display:none">
|
|
472
|
+
<table id="daily-table">
|
|
473
|
+
<thead>
|
|
474
|
+
<tr>
|
|
475
|
+
<th>Date</th>
|
|
476
|
+
<th>Requests</th>
|
|
477
|
+
<th>Prompt Tokens</th>
|
|
478
|
+
<th>Completion Tokens</th>
|
|
479
|
+
<th>Total Tokens</th>
|
|
480
|
+
<th>Errors</th>
|
|
481
|
+
</tr>
|
|
482
|
+
</thead>
|
|
483
|
+
<tbody id="daily-body"></tbody>
|
|
484
|
+
</table>
|
|
485
|
+
</div>
|
|
248
486
|
|
|
249
|
-
|
|
250
|
-
|
|
487
|
+
<div class="section-title">Per-Model Breakdown</div>
|
|
488
|
+
<div class="table-wrap">
|
|
489
|
+
<table id="model-usage-table">
|
|
490
|
+
<thead>
|
|
491
|
+
<tr>
|
|
492
|
+
<th>Model</th>
|
|
493
|
+
<th>Requests</th>
|
|
494
|
+
<th>Prompt Tokens</th>
|
|
495
|
+
<th>Completion Tokens</th>
|
|
496
|
+
<th>Avg Duration</th>
|
|
497
|
+
<th>Errors</th>
|
|
498
|
+
</tr>
|
|
499
|
+
</thead>
|
|
500
|
+
<tbody id="model-usage-body"></tbody>
|
|
501
|
+
</table>
|
|
502
|
+
</div>
|
|
251
503
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
504
|
+
<div class="section-title">Recent Requests</div>
|
|
505
|
+
<div class="table-wrap">
|
|
506
|
+
<table id="recent-table">
|
|
507
|
+
<thead>
|
|
508
|
+
<tr>
|
|
509
|
+
<th style="width:30px"></th>
|
|
510
|
+
<th>Model</th>
|
|
511
|
+
<th>Prompt</th>
|
|
512
|
+
<th>Completion</th>
|
|
513
|
+
<th>Duration</th>
|
|
514
|
+
<th>Time</th>
|
|
515
|
+
</tr>
|
|
516
|
+
</thead>
|
|
517
|
+
<tbody id="recent-body"></tbody>
|
|
518
|
+
</table>
|
|
519
|
+
</div>
|
|
520
|
+
<div id="usage-empty" class="empty-state" style="display:none">
|
|
521
|
+
No requests recorded yet. Send a chat completion to see usage.
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
256
524
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
525
|
+
<!-- ═══════════ TAB 3: Console ═══════════ -->
|
|
526
|
+
<div id="tab-console" class="tab-panel">
|
|
527
|
+
<div class="console-wrap">
|
|
528
|
+
<div class="console-toolbar">
|
|
529
|
+
<span class="title" id="console-status">● Connected</span>
|
|
530
|
+
<button onclick="clearLogs()">Clear</button>
|
|
531
|
+
</div>
|
|
532
|
+
<div class="console-output" id="console-output"></div>
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
535
|
+
</div>
|
|
536
|
+
|
|
537
|
+
<script>
|
|
538
|
+
/* ── Tab switching ──────────────────────────────────────────── */
|
|
539
|
+
const tabs = document.querySelectorAll('.tab');
|
|
540
|
+
tabs.forEach(t => t.addEventListener('click', () => {
|
|
541
|
+
tabs.forEach(x => x.classList.remove('active'));
|
|
542
|
+
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
|
543
|
+
t.classList.add('active');
|
|
544
|
+
document.getElementById('tab-' + t.dataset.tab).classList.add('active');
|
|
545
|
+
if (t.dataset.tab === 'usage') fetchUsage();
|
|
546
|
+
}));
|
|
547
|
+
|
|
548
|
+
/* ── Helpers ────────────────────────────────────────────────── */
|
|
549
|
+
function copyText(text, btn) {
|
|
550
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
551
|
+
btn.textContent = 'Copied!';
|
|
552
|
+
btn.classList.add('copied');
|
|
553
|
+
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1500);
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function fmtNum(n) {
|
|
558
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
559
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
|
|
560
|
+
return String(n);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function timeAgo(ts) {
|
|
564
|
+
const s = Math.floor((Date.now() - ts) / 1000);
|
|
565
|
+
if (s < 5) return 'just now';
|
|
566
|
+
if (s < 60) return s + 's ago';
|
|
567
|
+
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
|
568
|
+
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
|
569
|
+
return Math.floor(s / 86400) + 'd ago';
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function esc(s) {
|
|
573
|
+
const d = document.createElement('div');
|
|
574
|
+
d.textContent = s;
|
|
575
|
+
return d.innerHTML;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/* ── Tab 1: Models ──────────────────────────────────────────── */
|
|
579
|
+
async function fetchModels() {
|
|
580
|
+
try {
|
|
581
|
+
const res = await fetch('/v1/models');
|
|
582
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
583
|
+
const data = await res.json();
|
|
584
|
+
const models = (data.data || []).sort((a, b) => a.id.localeCompare(b.id));
|
|
585
|
+
const prefix = 'cus-';
|
|
586
|
+
|
|
587
|
+
document.getElementById('model-count').textContent = models.length + ' models';
|
|
588
|
+
document.getElementById('models-loading').style.display = 'none';
|
|
589
|
+
const table = document.getElementById('models-table');
|
|
590
|
+
table.style.display = '';
|
|
591
|
+
const tbody = document.getElementById('models-body');
|
|
592
|
+
tbody.innerHTML = '';
|
|
593
|
+
|
|
594
|
+
models.forEach(m => {
|
|
595
|
+
const origId = m.id.startsWith(prefix) ? m.id.slice(prefix.length) : m.id;
|
|
596
|
+
const tr = document.createElement('tr');
|
|
597
|
+
tr.innerHTML =
|
|
598
|
+
'<td><span class="model-badge">' + esc(m.id) + '</span></td>' +
|
|
599
|
+
'<td style="color:var(--text-dim)">' + esc(origId) + '</td>' +
|
|
600
|
+
'<td style="color:var(--text-dim)">' + esc(m.display_name || m.id) + '</td>' +
|
|
601
|
+
'<td><button class="copy-btn" onclick="copyText(\'' + esc(m.id).replace(/'/g, "\\'") + '\',this)">Copy</button></td>';
|
|
602
|
+
tbody.appendChild(tr);
|
|
603
|
+
});
|
|
604
|
+
} catch (e) {
|
|
605
|
+
document.getElementById('models-loading').textContent = 'Failed to load models: ' + e.message;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
fetchModels();
|
|
609
|
+
|
|
610
|
+
/* ── Tab 1: API Key Management ─────────────────────────────── */
|
|
611
|
+
let authConfig = { requireApiKey: false, keys: [] };
|
|
612
|
+
|
|
613
|
+
async function loadAuthConfigUI() {
|
|
614
|
+
try {
|
|
615
|
+
const resp = await fetch('/api/keys');
|
|
616
|
+
authConfig = await resp.json();
|
|
617
|
+
document.getElementById('requireKeyToggle').checked = authConfig.requireApiKey;
|
|
618
|
+
document.getElementById('authStatus').textContent = authConfig.requireApiKey
|
|
619
|
+
? '🔒 API key required for all requests'
|
|
620
|
+
: '🔓 No authentication — anyone with the URL can use the proxy';
|
|
621
|
+
document.getElementById('keysSection').style.display = 'block';
|
|
622
|
+
renderKeys();
|
|
623
|
+
} catch (e) {
|
|
624
|
+
document.getElementById('authStatus').textContent = 'Failed to load auth config';
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function renderKeys() {
|
|
629
|
+
const list = document.getElementById('keysList');
|
|
630
|
+
if (authConfig.keys.length === 0) {
|
|
631
|
+
list.innerHTML = '<div style="color: #666; font-size: 13px; padding: 12px 0;">No API keys created yet.</div>';
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
list.innerHTML = authConfig.keys.map(k => `
|
|
635
|
+
<div style="display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid #222;">
|
|
636
|
+
<label class="toggle" style="flex-shrink: 0;">
|
|
637
|
+
<input type="checkbox" ${k.active ? 'checked' : ''} onchange="toggleKey('${esc(k.id)}', this.checked)">
|
|
638
|
+
<span class="toggle-slider"></span>
|
|
639
|
+
</label>
|
|
640
|
+
<div style="flex: 1; min-width: 0;">
|
|
641
|
+
<div style="font-weight: 500; font-size: 14px;">${esc(k.name)}</div>
|
|
642
|
+
<code style="color: #888; font-size: 12px;">${esc(k.key)}</code>
|
|
643
|
+
</div>
|
|
644
|
+
<div style="color: #666; font-size: 12px; white-space: nowrap;">${timeAgo(k.createdAt)}</div>
|
|
645
|
+
<button onclick="deleteKey('${esc(k.id)}')" style="background: none; border: none; color: #666; cursor: pointer; font-size: 16px; padding: 4px 8px;" title="Delete">🗑</button>
|
|
646
|
+
</div>
|
|
647
|
+
`).join('');
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async function toggleRequireKey(enabled) {
|
|
651
|
+
await fetch('/api/settings/auth', { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ requireApiKey: enabled }) });
|
|
652
|
+
loadAuthConfigUI();
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function createKey() {
|
|
656
|
+
const name = document.getElementById('keyNameInput').value.trim() || 'Untitled';
|
|
657
|
+
const resp = await fetch('/api/keys', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ name }) });
|
|
658
|
+
const newKey = await resp.json();
|
|
659
|
+
document.getElementById('newKeyAlert').style.display = 'block';
|
|
660
|
+
document.getElementById('newKeyValue').textContent = newKey.key;
|
|
661
|
+
document.getElementById('keyNameInput').value = '';
|
|
662
|
+
loadAuthConfigUI();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function copyNewKey() {
|
|
666
|
+
navigator.clipboard.writeText(document.getElementById('newKeyValue').textContent);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function toggleKey(id, active) {
|
|
670
|
+
await fetch('/api/keys/' + id, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ active }) });
|
|
671
|
+
loadAuthConfigUI();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function deleteKey(id) {
|
|
675
|
+
if (!confirm('Delete this API key?')) return;
|
|
676
|
+
await fetch('/api/keys/' + id, { method: 'DELETE' });
|
|
677
|
+
document.getElementById('newKeyAlert').style.display = 'none';
|
|
678
|
+
loadAuthConfigUI();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
loadAuthConfigUI();
|
|
682
|
+
|
|
683
|
+
/* ── Tab 2: Usage ───────────────────────────────────────────── */
|
|
684
|
+
let usageTimer = null;
|
|
685
|
+
|
|
686
|
+
async function fetchUsage() {
|
|
687
|
+
try {
|
|
688
|
+
const res = await fetch('/api/usage');
|
|
689
|
+
if (!res.ok) return;
|
|
690
|
+
const d = await res.json();
|
|
691
|
+
|
|
692
|
+
document.getElementById('stat-requests').textContent = fmtNum(d.totalRequests);
|
|
693
|
+
document.getElementById('stat-prompt').textContent = fmtNum(d.totalPromptTokens);
|
|
694
|
+
document.getElementById('stat-completion').textContent = fmtNum(d.totalCompletionTokens);
|
|
695
|
+
document.getElementById('stat-total').textContent = fmtNum(d.totalTokens);
|
|
696
|
+
|
|
697
|
+
// Persistence bar
|
|
698
|
+
if (d.persistence) {
|
|
699
|
+
const bar = document.getElementById('persistence-bar');
|
|
700
|
+
bar.style.display = 'flex';
|
|
701
|
+
const saved = d.persistence.lastSavedAt ? timeAgo(d.persistence.lastSavedAt) : 'never';
|
|
702
|
+
const since = d.persistence.createdAt ? new Date(d.persistence.createdAt).toLocaleDateString() : '—';
|
|
703
|
+
document.getElementById('persist-info').textContent =
|
|
704
|
+
'Saved ' + saved + ' · Tracking since ' + since + ' · ' + d.persistence.file;
|
|
268
705
|
}
|
|
269
706
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
btn.classList.remove('copied');
|
|
278
|
-
}, 1500);
|
|
279
|
-
});
|
|
707
|
+
const empty = document.getElementById('usage-empty');
|
|
708
|
+
if (d.totalRequests === 0) {
|
|
709
|
+
empty.style.display = '';
|
|
710
|
+
document.getElementById('model-usage-table').parentElement.style.display = 'none';
|
|
711
|
+
document.getElementById('recent-table').parentElement.style.display = 'none';
|
|
712
|
+
document.getElementById('daily-wrap').style.display = 'none';
|
|
713
|
+
return;
|
|
280
714
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
715
|
+
empty.style.display = 'none';
|
|
716
|
+
document.getElementById('model-usage-table').parentElement.style.display = '';
|
|
717
|
+
document.getElementById('recent-table').parentElement.style.display = '';
|
|
718
|
+
|
|
719
|
+
// Daily snapshots
|
|
720
|
+
if (d.dailySnapshots && d.dailySnapshots.length > 0) {
|
|
721
|
+
document.getElementById('daily-wrap').style.display = '';
|
|
722
|
+
const dBody = document.getElementById('daily-body');
|
|
723
|
+
dBody.innerHTML = '';
|
|
724
|
+
d.dailySnapshots.slice().reverse().forEach(s => {
|
|
725
|
+
const tr = document.createElement('tr');
|
|
726
|
+
tr.innerHTML =
|
|
727
|
+
'<td>' + esc(s.date) + '</td>' +
|
|
728
|
+
'<td class="num">' + fmtNum(s.requests) + '</td>' +
|
|
729
|
+
'<td class="num">' + fmtNum(s.promptTokens) + '</td>' +
|
|
730
|
+
'<td class="num">' + fmtNum(s.completionTokens) + '</td>' +
|
|
731
|
+
'<td class="num">' + fmtNum(s.totalTokens) + '</td>' +
|
|
732
|
+
'<td class="num" style="color:' + (s.errors > 0 ? 'var(--red)' : 'var(--text-dim)') + '">' + s.errors + '</td>';
|
|
733
|
+
dBody.appendChild(tr);
|
|
734
|
+
});
|
|
735
|
+
} else {
|
|
736
|
+
document.getElementById('daily-wrap').style.display = 'none';
|
|
293
737
|
}
|
|
294
738
|
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
|
|
739
|
+
// Per-model
|
|
740
|
+
const mBody = document.getElementById('model-usage-body');
|
|
741
|
+
mBody.innerHTML = '';
|
|
742
|
+
(d.byModel || []).sort((a, b) => b.requests - a.requests).forEach(m => {
|
|
743
|
+
const tr = document.createElement('tr');
|
|
744
|
+
tr.innerHTML =
|
|
745
|
+
'<td><span class="model-badge">' + esc(m.model) + '</span></td>' +
|
|
746
|
+
'<td class="num">' + fmtNum(m.requests) + '</td>' +
|
|
747
|
+
'<td class="num">' + fmtNum(m.promptTokens) + '</td>' +
|
|
748
|
+
'<td class="num">' + fmtNum(m.completionTokens) + '</td>' +
|
|
749
|
+
'<td class="num">' + m.avgDuration + 'ms</td>' +
|
|
750
|
+
'<td class="num" style="color:' + (m.errors > 0 ? 'var(--red)' : 'var(--text-dim)') + '">' + m.errors + '</td>';
|
|
751
|
+
mBody.appendChild(tr);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// Recent requests
|
|
755
|
+
const rBody = document.getElementById('recent-body');
|
|
756
|
+
rBody.innerHTML = '';
|
|
757
|
+
(d.recentRequests || []).forEach(r => {
|
|
758
|
+
const ok = r.status < 400;
|
|
759
|
+
const tr = document.createElement('tr');
|
|
760
|
+
tr.innerHTML =
|
|
761
|
+
'<td><span class="status-dot ' + (ok ? 'ok' : 'err') + '"></span></td>' +
|
|
762
|
+
'<td><span class="model-badge">' + esc(r.model) + '</span></td>' +
|
|
763
|
+
'<td class="num">' + fmtNum(r.promptTokens) + '</td>' +
|
|
764
|
+
'<td class="num">' + fmtNum(r.completionTokens) + '</td>' +
|
|
765
|
+
'<td class="num">' + r.duration + 'ms</td>' +
|
|
766
|
+
'<td style="color:var(--text-dim)">' + timeAgo(r.timestamp) + '</td>';
|
|
767
|
+
rBody.appendChild(tr);
|
|
768
|
+
});
|
|
769
|
+
} catch (e) { /* silent */ }
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function flushUsage() {
|
|
773
|
+
const btn = document.getElementById('flush-btn');
|
|
774
|
+
btn.textContent = 'Saving…';
|
|
775
|
+
try {
|
|
776
|
+
await fetch('/api/usage/flush', { method: 'POST' });
|
|
777
|
+
btn.textContent = 'Saved!';
|
|
778
|
+
btn.classList.add('copied');
|
|
779
|
+
setTimeout(() => { btn.textContent = 'Save Now'; btn.classList.remove('copied'); }, 1500);
|
|
780
|
+
fetchUsage();
|
|
781
|
+
} catch {
|
|
782
|
+
btn.textContent = 'Failed';
|
|
783
|
+
setTimeout(() => { btn.textContent = 'Save Now'; }, 1500);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Auto-refresh usage every 5s when tab is active
|
|
788
|
+
setInterval(() => {
|
|
789
|
+
if (document.getElementById('tab-usage').classList.contains('active')) fetchUsage();
|
|
790
|
+
}, 5000);
|
|
791
|
+
fetchUsage();
|
|
792
|
+
|
|
793
|
+
/* ── Tab 3: Console ─────────────────────────────────────────── */
|
|
794
|
+
const output = document.getElementById('console-output');
|
|
795
|
+
let autoScroll = true;
|
|
796
|
+
|
|
797
|
+
output.addEventListener('scroll', () => {
|
|
798
|
+
autoScroll = (output.scrollTop + output.clientHeight >= output.scrollHeight - 30);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
function appendLine(ts, level, message) {
|
|
802
|
+
const d = new Date(ts);
|
|
803
|
+
const time = d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
804
|
+
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
|
805
|
+
const span = document.createElement('span');
|
|
806
|
+
span.className = 'log-line ' + level;
|
|
807
|
+
span.innerHTML =
|
|
808
|
+
'<span class="ts">' + time + '.' + ms + '</span>' +
|
|
809
|
+
'<span class="lvl">[' + level + ']</span> ' +
|
|
810
|
+
'<span class="msg">' + esc(message) + '</span>';
|
|
811
|
+
output.appendChild(span);
|
|
812
|
+
if (autoScroll) output.scrollTop = output.scrollHeight;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function connectSSE() {
|
|
816
|
+
const status = document.getElementById('console-status');
|
|
817
|
+
const es = new EventSource('/api/logs/stream');
|
|
818
|
+
|
|
819
|
+
es.onopen = () => { status.textContent = '● Connected'; status.style.color = 'var(--green)'; };
|
|
820
|
+
es.onerror = () => { status.textContent = '● Disconnected'; status.style.color = 'var(--red)'; };
|
|
821
|
+
|
|
822
|
+
es.onmessage = (evt) => {
|
|
823
|
+
try {
|
|
824
|
+
const d = JSON.parse(evt.data);
|
|
825
|
+
if (d.type === 'init') {
|
|
826
|
+
output.innerHTML = '';
|
|
827
|
+
(d.lines || []).forEach(l => appendLine(l.timestamp, l.level, l.message));
|
|
828
|
+
} else if (d.type === 'line') {
|
|
829
|
+
appendLine(d.timestamp, d.level, d.message);
|
|
830
|
+
} else if (d.type === 'clear') {
|
|
831
|
+
output.innerHTML = '';
|
|
832
|
+
}
|
|
833
|
+
} catch {}
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
connectSSE();
|
|
837
|
+
|
|
838
|
+
function clearLogs() {
|
|
839
|
+
fetch('/api/logs/clear', { method: 'POST' });
|
|
840
|
+
}
|
|
841
|
+
</script>
|
|
298
842
|
</body>
|
|
299
843
|
</html>
|