copilot-cursor-proxy 1.0.1 → 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/dashboard.html CHANGED
@@ -1,299 +1,843 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Copilot API Dashboard</title>
7
- <style>
8
- :root {
9
- --bg-color: #fcfcfc;
10
- --text-color: #1a1a1a;
11
- --border-color: #e0e0e0;
12
- --accent-gray: #666666;
13
- --hover-bg: #f5f5f5;
14
- }
15
-
16
- body {
17
- font-family: 'Times New Roman', Times, serif; /* Roman style */
18
- background-color: var(--bg-color);
19
- color: var(--text-color);
20
- margin: 0;
21
- padding: 40px;
22
- line-height: 1.6;
23
- max-width: 900px;
24
- margin-left: auto;
25
- margin-right: auto;
26
- }
27
-
28
- h1, h2, h3 {
29
- font-weight: normal;
30
- letter-spacing: 0.05em;
31
- text-transform: uppercase;
32
- margin-bottom: 1.5rem;
33
- border-bottom: 1px solid var(--text-color);
34
- padding-bottom: 10px;
35
- }
36
-
37
- h1 { font-size: 2rem; margin-top: 0; }
38
- h2 { font-size: 1.2rem; margin-top: 3rem; color: var(--accent-gray); border-bottom: 1px solid var(--border-color); }
39
-
40
- .container {
41
- display: flex;
42
- flex-direction: column;
43
- gap: 20px;
44
- }
45
-
46
- /* Usage Section */
47
- .usage-grid {
48
- display: grid;
49
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
50
- gap: 20px;
51
- margin-bottom: 20px;
52
- }
53
-
54
- .card {
55
- border: 1px solid var(--border-color);
56
- padding: 20px;
57
- background: white;
58
- transition: all 0.2s ease;
59
- }
60
-
61
- .card:hover {
62
- border-color: var(--accent-gray);
63
- }
64
-
65
- .label {
66
- font-size: 0.85rem;
67
- color: var(--accent-gray);
68
- text-transform: uppercase;
69
- letter-spacing: 0.1em;
70
- margin-bottom: 5px;
71
- }
72
-
73
- .value {
74
- font-size: 1.2rem;
75
- font-weight: bold;
76
- }
77
-
78
- /* Models Table */
79
- table {
80
- width: 100%;
81
- border-collapse: collapse;
82
- font-size: 0.95rem;
83
- margin-top: 10px;
84
- }
85
-
86
- th {
87
- text-align: left;
88
- padding: 15px;
89
- border-bottom: 2px solid var(--text-color);
90
- font-weight: normal;
91
- text-transform: uppercase;
92
- font-size: 0.8rem;
93
- letter-spacing: 0.1em;
94
- }
95
-
96
- td {
97
- padding: 15px;
98
- border-bottom: 1px solid var(--border-color);
99
- }
100
-
101
- tr:hover td {
102
- background-color: var(--hover-bg);
103
- }
104
-
105
- .model-id {
106
- font-family: 'Courier New', Courier, monospace; /* Monospace for code */
107
- background: #f4f4f4;
108
- padding: 4px 8px;
109
- border-radius: 2px;
110
- font-size: 0.9em;
111
- }
112
-
113
- .copy-btn {
114
- background: transparent;
115
- border: 1px solid var(--border-color);
116
- color: var(--accent-gray);
117
- cursor: pointer;
118
- padding: 4px 10px;
119
- font-family: inherit;
120
- font-size: 0.8rem;
121
- text-transform: uppercase;
122
- margin-left: 10px;
123
- transition: all 0.2s;
124
- }
125
-
126
- .copy-btn:hover {
127
- background: var(--text-color);
128
- color: white;
129
- border-color: var(--text-color);
130
- }
131
-
132
- .copy-btn.copied {
133
- background: var(--text-color);
134
- color: white;
135
- border-color: var(--text-color);
136
- }
137
-
138
- #loading {
139
- text-align: center;
140
- color: var(--accent-gray);
141
- font-style: italic;
142
- padding: 40px;
143
- }
144
-
145
- .error {
146
- color: #333;
147
- border: 1px solid #333;
148
- padding: 20px;
149
- text-align: center;
150
- }
151
- </style>
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
- <h1>Copilot API Dashboard</h1>
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
- <div id="loading">Fetching data from Proxy...</div>
158
- <div id="error-msg" class="error" style="display: none;"></div>
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
- <div id="content" style="display: none;">
161
- <!-- Usage Section -->
162
- <h2>Account & Quota</h2>
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">User</div>
166
- <div class="value" id="user-login">-</div>
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">Plan</div>
170
- <div class="value" id="plan-type">-</div>
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">Chat Quota</div>
174
- <div class="value" id="chat-quota">-</div>
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">Reset Date</div>
178
- <div class="value" id="reset-date">-</div>
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
- <!-- Models Section -->
183
- <h2>Available Models <span style="font-size: 0.8rem; float: right; margin-top: 5px; color: var(--accent-gray);" id="model-count"></span></h2>
184
- <p style="font-size: 0.9rem; color: #666; margin-bottom: 20px;">
185
- ℹ️ Use the <strong>Cursor Model ID</strong> when configuring Cursor to bypass internal routing.
186
- </p>
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
- // 2. Render Models
245
- const tbody = document.getElementById('models-list');
246
- const modelList = models.data || [];
247
- document.getElementById('model-count').textContent = `${modelList.length} MODELS FOUND`;
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
- // Sort models alphabetically by ID
250
- modelList.sort((a, b) => a.id.localeCompare(b.id));
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
- modelList.forEach(m => {
253
- // The proxy already adds the prefix to 'id' and 'display_name'
254
- const prefix = "cus-";
255
- const originalId = m.id.startsWith(prefix) ? m.id.slice(prefix.length) : m.id;
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
- const tr = document.createElement('tr');
258
- tr.innerHTML = `
259
- <td style="font-style: italic;">${m.display_name || m.id}</td>
260
- <td><span class="model-id" style="color: #888;">${originalId}</span></td>
261
- <td>
262
- <span class="model-id" style="font-weight: bold;">${m.id}</span>
263
- <button class="copy-btn" onclick="copyToClipboard('${m.id}', this)">Copy</button>
264
- </td>
265
- `;
266
- tbody.appendChild(tr);
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
- function copyToClipboard(text, btn) {
271
- navigator.clipboard.writeText(text).then(() => {
272
- const originalText = btn.textContent;
273
- btn.textContent = 'Copied';
274
- btn.classList.add('copied');
275
- setTimeout(() => {
276
- btn.textContent = originalText;
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
- function showError(msg) {
283
- document.getElementById('loading').style.display = 'none';
284
- const errDiv = document.getElementById('error-msg');
285
- errDiv.style.display = 'block';
286
- errDiv.innerHTML = `
287
- <strong>Connection Error</strong><br>
288
- ${msg}<br><br>
289
- Ensure both services are running:<br>
290
- 1. <code>npx copilot-api start</code> (Port 4141)<br>
291
- 2. <code>bun run proxy-router.ts</code> (Port 4142)
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
- // Start
296
- fetchData();
297
- </script>
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>