pi-antigravity-rotator 2.2.2 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2872 @@
1
+ function formatDuration(ms) {
2
+ if (ms <= 0) return "--";
3
+ var s = Math.floor(ms / 1000);
4
+ if (s < 60) return s + "s";
5
+ var m = Math.floor(s / 60);
6
+ if (m < 60) return m + "m " + (s % 60) + "s";
7
+ var h = Math.floor(m / 60);
8
+ if (h < 24) return h + "h " + (m % 60) + "m";
9
+ var d = Math.floor(h / 24);
10
+ return d + "d " + (h % 24) + "h " + (m % 60) + "m";
11
+ }
12
+
13
+ function formatTime(ts) {
14
+ if (!ts) return "--";
15
+ return new Date(ts).toLocaleTimeString();
16
+ }
17
+
18
+ function quotaBarColor(pct) {
19
+ if (pct >= 60) return "var(--green)";
20
+ if (pct >= 30) return "var(--yellow)";
21
+ return "var(--red)";
22
+ }
23
+
24
+ function timerDisplayLabel(timerType) {
25
+ return timerType === "fresh" ? "idle" : timerType;
26
+ }
27
+
28
+ // A pool is "idle" (worth kickstarting) when either:
29
+ // 1. The server reports timerType === "fresh" (no active timer at all), or
30
+ // 2. Google is showing a "rolling" timer: 100% quota AND remaining time is
31
+ // very close to a full 5h or 7d window (the timer exists but hasn't been
32
+ // touched yet — a kickstart request will start consuming from it).
33
+ function isIdleForKickstart(q, now) {
34
+ if (q.timerType === "fresh") return true;
35
+ if (!q.resetTime || q.percentRemaining !== 100) return false;
36
+ var remaining = new Date(q.resetTime).getTime() - now;
37
+ if (remaining <= 0) return false;
38
+ var isRolling5h = Math.abs(remaining - 5 * 3600000) < 600000;
39
+ var isRolling7d = Math.abs(remaining - 7 * 86400000) < 600000;
40
+ return isRolling5h || isRolling7d;
41
+ }
42
+
43
+ function renderQuotaBars(account) {
44
+ var quota = account.quota;
45
+ if (!quota || quota.length === 0) return "";
46
+ var now = Date.now();
47
+ var rows = quota
48
+ .map(function (q) {
49
+ var inFlightForModel = (account.inFlightByModel || {})[q.modelKey] || 0;
50
+ var clearButton =
51
+ inFlightForModel > 0
52
+ ? '<button class="btn-clear-flight" title="Clear in-flight counter for ' +
53
+ escapeHtml(q.displayName) +
54
+ '" onclick="clearInFlight(\'' +
55
+ jsString(account.email) +
56
+ "', '" +
57
+ jsString(q.modelKey) +
58
+ "')\">" +
59
+ "Clear</button>"
60
+ : '<button class="btn-clear-flight" title="No in-flight requests for ' +
61
+ q.displayName +
62
+ '" disabled>Clear</button>';
63
+ var idle = isIdleForKickstart(q, now);
64
+ var kickstartBtn = "";
65
+ var color = quotaBarColor(q.percentRemaining);
66
+ var timerClass = "timer-" + q.timerType;
67
+ var resetLabel = "";
68
+ if (idle && q.resetTime && q.timerType !== "fresh") {
69
+ // Rolling idle timer already has resetTime set; show "idle" label
70
+ resetLabel = '<span style="color:var(--text-dim)">idle</span>';
71
+ } else if (q.resetTime && q.timerType !== "fresh") {
72
+ var remaining = new Date(q.resetTime).getTime() - Date.now();
73
+ if (remaining > 0) {
74
+ resetLabel = formatDuration(remaining);
75
+ }
76
+ }
77
+ return (
78
+ '<div class="quota-row">' +
79
+ '<span class="quota-model">' +
80
+ escapeHtml(q.displayName) +
81
+ "</span>" +
82
+ '<div class="quota-bar-bg"><div class="quota-bar-fill" style="width:' +
83
+ q.percentRemaining +
84
+ "%;background:" +
85
+ color +
86
+ '"></div></div>' +
87
+ '<span class="quota-pct" style="color:' +
88
+ color +
89
+ '">' +
90
+ q.percentRemaining +
91
+ "%</span>" +
92
+ '<span class="quota-reset">' +
93
+ (resetLabel || "--") +
94
+ "</span>" +
95
+ '<span class="quota-action">' +
96
+ clearButton +
97
+ kickstartBtn +
98
+ "</span>" +
99
+ "</div>"
100
+ );
101
+ })
102
+ .join("");
103
+ return (
104
+ '<div class="quota-section"><div class="quota-section-title">Quota (per model)</div>' +
105
+ rows +
106
+ "</div>"
107
+ );
108
+ }
109
+
110
+ function renderAccounts(data) {
111
+ window.__lastData = data;
112
+ var now = Date.now();
113
+ document.getElementById("uptime").textContent = formatDuration(data.uptime);
114
+ document.getElementById("port").textContent = data.proxyPort;
115
+ document.getElementById("rotation").textContent = data.requestsPerRotation;
116
+ document.getElementById("headerVersion").textContent =
117
+ "v" + escapeHtml(data.version || "unknown");
118
+ document.getElementById("lastRefresh").textContent = new Date(
119
+ now,
120
+ ).toLocaleTimeString();
121
+ document.getElementById("totalRequests").textContent =
122
+ data.totalRequestsAllAccounts;
123
+
124
+ var routingHealth = document.getElementById("routingHealth");
125
+ var health = data.routingHealth || {};
126
+ var controls = data.operatorControls || {};
127
+ var state = health.state || "stopped";
128
+ var stateColor = {
129
+ healthy: "var(--green)",
130
+ paused: "var(--red)",
131
+ cooldown_wait: "var(--yellow)",
132
+ busy: "var(--blue)",
133
+ stopped: "var(--red)",
134
+ }[state];
135
+ routingHealth.className = "routing-panel state-" + state;
136
+ var nextRetry =
137
+ health.nextRetryIn > 0
138
+ ? '<div style="margin-top:6px;">Next retry window: <span style="font-family:JetBrains Mono, monospace;">' +
139
+ formatDuration(health.nextRetryIn) +
140
+ "</span></div>"
141
+ : "";
142
+ var pauseWindow =
143
+ data.protectivePauseRemaining > 0
144
+ ? '<div style="margin-top:6px;">Protective pause: <span style="font-family:JetBrains Mono, monospace;">' +
145
+ formatDuration(data.protectivePauseRemaining) +
146
+ "</span> remaining</div>"
147
+ : "";
148
+ var freshPolicy = controls.allowFreshWindowStarts
149
+ ? '<div style="margin-top:6px;">Fresh windows: <span style="font-family:JetBrains Mono, monospace;color:var(--green)">allowed</span></div>'
150
+ : '<div style="margin-top:6px;">Fresh windows: <span style="font-family:JetBrains Mono, monospace;color:var(--yellow)">blocked</span></div>';
151
+ var freshPolicyHint = controls.allowFreshWindowStarts
152
+ ? "The rotator may start fresh windows when they are the best available option."
153
+ : "Fresh windows are being held back. Timed 5h buckets still win first, timed 7d buckets still run, but the rotator will not open fresh windows until you re-enable them.";
154
+ var autoWarmupHint = controls.autoWarmupEnabled
155
+ ? "Auto-warmup is enabled. Accounts with the fresh-window override will automatically receive minimal kickstart requests each quota poll cycle."
156
+ : "Auto-warmup is disabled. Use the per-account \u25b6 Start buttons or the per-account Start Idle Timers button to kickstart timers manually.";
157
+ var healthGrid =
158
+ '<div class="health-grid">' +
159
+ renderHealthPill("Available", health.availableCount || 0) +
160
+ renderHealthPill("Active", health.activeCount || 0) +
161
+ renderHealthPill("Ready", health.readyCount || 0) +
162
+ renderHealthPill("Cooldown", health.cooldownCount || 0) +
163
+ renderHealthPill("Busy", health.busyCount || 0) +
164
+ renderHealthPill("Flagged", health.flaggedCount || 0) +
165
+ renderHealthPill("Disabled", health.disabledCount || 0) +
166
+ renderHealthPill("Error", health.errorCount || 0) +
167
+ "</div>";
168
+ routingHealth.innerHTML =
169
+ '<div class="routing-summary">' +
170
+ '<strong style="color:' +
171
+ stateColor +
172
+ '">Routing: ' +
173
+ escapeHtml(String(health.state || "unknown").replace(/_/g, " ")) +
174
+ "</strong>" +
175
+ "<div>" +
176
+ escapeHtml(health.reason || "No routing health information available") +
177
+ "</div>" +
178
+ (nextRetry
179
+ ? "<div>" +
180
+ nextRetry
181
+ .replace('<div style="margin-top:6px;">', "")
182
+ .replace("</div>", "") +
183
+ "</div>"
184
+ : "") +
185
+ (pauseWindow
186
+ ? "<div>" +
187
+ pauseWindow
188
+ .replace('<div style="margin-top:6px;">', "")
189
+ .replace("</div>", "") +
190
+ "</div>"
191
+ : "") +
192
+ '<div class="routing-inline-note">' +
193
+ freshPolicy
194
+ .replace('<div style="margin-top:6px;">', "")
195
+ .replace("</div>", "") +
196
+ "</div>" +
197
+ "</div>" +
198
+ (data.protectivePauseReason && data.protectivePauseRemaining > 0
199
+ ? '<div style="margin-top:6px;color:var(--text-dim);font-family:JetBrains Mono, monospace;">' +
200
+ escapeHtml(data.protectivePauseReason.slice(0, 220)) +
201
+ "</div>"
202
+ : "") +
203
+ healthGrid +
204
+ '<div class="ops-buttons">' +
205
+ '<button class="btn-secondary" onclick="refresh()">Refresh</button>' +
206
+ '<button class="btn-secondary" onclick="openCliLogin()">Add Account</button>' +
207
+ '<button class="btn-secondary" onclick="openRoutingInspectorModal()">Routing Inspector</button>' +
208
+ '<button class="btn-secondary" onclick="openConfigEditorModal()">Config Editor</button>' +
209
+ '<button class="btn-secondary" onclick="toggleFlagged()">' +
210
+ (window.__hideFlagged ? "Show Flagged" : "Hide Flagged") +
211
+ "</button>" +
212
+ '<button class="btn-secondary" onclick="setFreshWindowStarts(' +
213
+ !controls.allowFreshWindowStarts +
214
+ ')">' +
215
+ (controls.allowFreshWindowStarts
216
+ ? "Block Fresh Windows"
217
+ : "Allow Fresh Windows") +
218
+ "</button>" +
219
+ '<button class="btn-secondary" onclick="setAutoWarmup(' +
220
+ !controls.autoWarmupEnabled +
221
+ ')">' +
222
+ (controls.autoWarmupEnabled ? "Disable Auto-Warmup" : "Enable Auto-Warmup") +
223
+ "</button>" +
224
+ (Object.keys(data.circuitBreakers.model || {}).length > 0 ||
225
+ Object.keys(data.circuitBreakers.project || {}).length > 0
226
+ ? '<button class="btn-secondary" style="border-color:var(--red);color:var(--red)" onclick="clearCircuitBreaker()">Reset All Circuit Breakers</button>'
227
+ : "") +
228
+ "</div>" +
229
+ '<div class="ops-warning">' +
230
+ freshPolicyHint +
231
+ "</div>" +
232
+ '<div class="ops-warning">' +
233
+ autoWarmupHint +
234
+ "</div>";
235
+
236
+ if (
237
+ Object.keys(data.circuitBreakers.model || {}).length > 0 ||
238
+ Object.keys(data.circuitBreakers.project || {}).length > 0
239
+ ) {
240
+ var breakerHtml =
241
+ '<div style="margin-top:12px;padding:12px;border:1px solid rgba(248, 113, 113, 0.3);border-radius:8px;background:rgba(248, 113, 113, 0.05);">';
242
+ breakerHtml +=
243
+ '<strong style="color:var(--red);display:block;margin-bottom:8px">Active Circuit Breakers</strong>';
244
+ for (var key in data.circuitBreakers.model) {
245
+ breakerHtml +=
246
+ '<div style="display:flex;justify-content:space-between;align-items:center;font-size:12px;margin-bottom:4px;color:var(--text-dim)">' +
247
+ '<span>Model <span style="font-family:monospace;color:var(--text)">' +
248
+ escapeHtml(key) +
249
+ "</span></span>" +
250
+ '<div style="display:flex;gap:8px;align-items:center">' +
251
+ '<span style="color:var(--yellow)">' +
252
+ formatDuration(data.circuitBreakers.model[key].remainingMs) +
253
+ " left</span>" +
254
+ '<button class="btn-clear-flight" style="border-color:var(--red);color:var(--red)" onclick="clearCircuitBreaker(\'' +
255
+ jsString(key) +
256
+ "')\">Reset</button>" +
257
+ "</div></div>";
258
+ }
259
+ for (var pkey in data.circuitBreakers.project) {
260
+ breakerHtml +=
261
+ '<div style="display:flex;justify-content:space-between;align-items:center;font-size:12px;margin-bottom:4px;color:var(--text-dim)">' +
262
+ '<span>Project/Model <span style="font-family:monospace;color:var(--text)">' +
263
+ escapeHtml(pkey) +
264
+ "</span></span>" +
265
+ '<span style="color:var(--yellow)">' +
266
+ formatDuration(data.circuitBreakers.project[pkey].remainingMs) +
267
+ " left</span>" +
268
+ "</div>";
269
+ }
270
+ breakerHtml += "</div>";
271
+ routingHealth.innerHTML += breakerHtml;
272
+ }
273
+
274
+ renderUpdateBanner(data.updateInfo);
275
+ renderNotifications(data.notifications);
276
+ renderAttentionPanel(data);
277
+ renderRoutingInspector(data);
278
+ renderTokenChart(data.tokenUsage);
279
+ renderHeatmap(data.tokenUsage);
280
+ renderLatencyPanel(data.latencyStats);
281
+ renderForecastPanel(data);
282
+ renderRequestLog(data.requestLog);
283
+ renderRecentEvents(data.recentEvents);
284
+
285
+ var container = document.getElementById("accounts");
286
+ var hideFlagged = window.__hideFlagged || false;
287
+ var sorted = data.accounts
288
+ .slice()
289
+ .filter(function (a) {
290
+ return (
291
+ !hideFlagged || (a.status !== "flagged" && a.status !== "disabled")
292
+ );
293
+ })
294
+ .sort(function (a, b) {
295
+ var aFlagged = a.status === "flagged" || a.status === "disabled" ? 1 : 0;
296
+ var bFlagged = b.status === "flagged" || b.status === "disabled" ? 1 : 0;
297
+ if (aFlagged !== bFlagged) return aFlagged - bFlagged;
298
+ var aQuota = (a.quota || []).reduce(function (s, q) {
299
+ return s + q.percentRemaining;
300
+ }, 0);
301
+ var bQuota = (b.quota || []).reduce(function (s, q) {
302
+ return s + q.percentRemaining;
303
+ }, 0);
304
+ return bQuota - aQuota;
305
+ });
306
+ container.innerHTML = sorted
307
+ .map(function (a) {
308
+ var isActive = a.status === "active";
309
+ var isCooldown = a.status === "cooldown" || a.status === "exhausted";
310
+ var now = Date.now();
311
+ var cooldowns = Object.values(a.cooldownsByModel || {});
312
+ var maxCooldownUntil =
313
+ cooldowns.length > 0 ? Math.max.apply(null, cooldowns) : 0;
314
+ var cooldownRemaining = Math.max(0, maxCooldownUntil - now);
315
+ var cooldownPercent = 0;
316
+ if (isCooldown && cooldownRemaining > 0) {
317
+ var totalCooldown = maxCooldownUntil - (a.lastUsed || now);
318
+ cooldownPercent = Math.max(
319
+ 0,
320
+ Math.min(100, (cooldownRemaining / Math.max(totalCooldown, 1)) * 100),
321
+ );
322
+ }
323
+ var modelBadges = (a.activeForModels || [])
324
+ .map(function (m) {
325
+ if (m.startsWith("claude")) {
326
+ return '<span class="badge badge-model">CLAUDE</span>';
327
+ }
328
+ if (m === "gemini-3.1-pro") {
329
+ return '<span class="badge badge-model">PRO</span>';
330
+ }
331
+ if (m === "gemini-3.5-flash") {
332
+ return '<span class="badge badge-model">FLASH</span>';
333
+ }
334
+ return "";
335
+ })
336
+ .join("");
337
+ var tierLabel = a.tier ? String(a.tier).toUpperCase() : "UNKNOWN";
338
+
339
+ return (
340
+ '<div class="account-card ' +
341
+ escapeHtml(a.status) +
342
+ '" data-account-email="' +
343
+ escapeHtml(a.email) +
344
+ '">' +
345
+ '<div class="card-header">' +
346
+ '<div class="card-label">' +
347
+ escapeHtml(maskText(a.label)) +
348
+ "</div>" +
349
+ '<div class="card-badges">' +
350
+ '<span class="badge badge-' +
351
+ escapeHtml(a.status) +
352
+ (isActive ? " pulse" : "") +
353
+ '">' +
354
+ escapeHtml(a.status) +
355
+ "</span>" +
356
+ '<div style="position:relative;display:inline-block">' +
357
+ '<span class="badge badge-model" onclick="toggleTierDropdown(this, \'' +
358
+ jsString(a.email) +
359
+ '\')" style="cursor:pointer" title="Click to change tier">' +
360
+ escapeHtml(tierLabel) +
361
+ " ▾</span>" +
362
+ '<div class="tier-dropdown" style="display:none;position:absolute;top:100%;right:0;z-index:100;background:var(--surface);border:1px solid var(--border);border-radius:8px;margin-top:4px;min-width:100px;box-shadow:0 8px 24px rgba(0,0,0,0.4)">' +
363
+ '<div class="tier-option" onclick="setAccountTier(\'' +
364
+ jsString(a.email) +
365
+ "', 'unknown')\">" +
366
+ "UNKNOWN</div>" +
367
+ '<div class="tier-option" onclick="setAccountTier(\'' +
368
+ jsString(a.email) +
369
+ "', 'free')\">" +
370
+ "FREE</div>" +
371
+ '<div class="tier-option" onclick="setAccountTier(\'' +
372
+ jsString(a.email) +
373
+ "', 'pro')\">" +
374
+ "PRO</div>" +
375
+ '<div class="tier-option" onclick="setAccountTier(\'' +
376
+ jsString(a.email) +
377
+ "', 'ultra')\">" +
378
+ "ULTRA</div>" +
379
+ "</div>" +
380
+ "</div>" +
381
+ modelBadges +
382
+ "</div>" +
383
+ "</div>" +
384
+ '<div class="card-email">' +
385
+ escapeHtml(maskEmail(a.email)) +
386
+ "</div>" +
387
+ (a.quota && a.quota.length > 0 ? renderQuotaBars(a) : "") +
388
+ '<div class="card-stats">' +
389
+ '<div class="card-stat"><div class="stat-label">Requests</div><div class="stat-value">' +
390
+ a.requestsSinceRotation +
391
+ " / " +
392
+ a.totalRequests +
393
+ " total</div></div>" +
394
+ '<div class="card-stat"><div class="stat-label">Last Used</div><div class="stat-value">' +
395
+ (a.lastUsed ? formatTime(a.lastUsed) : "--") +
396
+ "</div></div>" +
397
+ (isCooldown
398
+ ? '<div class="card-stat"><div class="stat-label">Cooldown</div><div class="stat-value" style="color:var(--yellow)">' +
399
+ formatDuration(cooldownRemaining) +
400
+ "</div></div>"
401
+ : "") +
402
+ (a.inFlightRequests > 0
403
+ ? '<div class="card-stat"><div class="stat-label">In Flight</div><div class="stat-value" style="color:var(--blue)">' +
404
+ a.inFlightRequests +
405
+ "</div></div>"
406
+ : "") +
407
+ '<div class="card-stat"><div class="stat-label">Token</div><div class="stat-value" style="color:' +
408
+ (a.hasValidToken ? "var(--green)" : "var(--text-dim)") +
409
+ '">' +
410
+ (a.hasValidToken ? "Valid" : "Expired") +
411
+ "</div></div>" +
412
+ '<div class="card-stat"><div class="stat-label">Health</div><div class="stat-value">' +
413
+ Math.round((a.healthScore || 0) * 100) +
414
+ "%</div></div>" +
415
+ '<div class="card-stat"><div class="stat-label">Fresh Policy</div><div class="stat-value" style="color:' +
416
+ (a.effectiveFreshWindowStartsAllowed
417
+ ? "var(--green)"
418
+ : "var(--yellow)") +
419
+ '">' +
420
+ (a.allowFreshWindowStartsOverride
421
+ ? "Override ON"
422
+ : a.effectiveFreshWindowStartsAllowed
423
+ ? "Global ON"
424
+ : "Blocked") +
425
+ "</div></div>" +
426
+ "</div>" +
427
+ (a.lastError
428
+ ? '<div class="card-error">' +
429
+ escapeHtml(a.lastError.slice(0, 150)) +
430
+ "</div>" +
431
+ (a.lastError.toLowerCase().includes("verif")
432
+ ? '<div class="card-hint">Open Antigravity IDE, sign in with this account, and resolve the verification prompt outside the rotator. Keep the account quarantined until that is complete.</div>'
433
+ : a.lastError.toLowerCase().includes("terms of service")
434
+ ? '<div class="card-hint">This account was suspended by Google. Submit an appeal at <a href="https://support.google.com/accounts/troubleshooter/2402620" target="_blank" style="color:var(--blue)">Google Account Recovery</a> and keep it out of rotation unless Google explicitly restores access.</div>'
435
+ : "")
436
+ : "") +
437
+ (isCooldown
438
+ ? '<div class="card-hint">Cooling down after a provider rate-limit response. The rotator will wait for the retry window instead of forcing more traffic into this account.</div>'
439
+ : "") +
440
+ '<div class="card-actions">' +
441
+ (a.status === "disabled"
442
+ ? '<button class="btn-enable" onclick="enableAccount(\'' +
443
+ jsString(a.email) +
444
+ "')\">Re-enable</button>"
445
+ : "") +
446
+ (a.status !== "disabled"
447
+ ? '<button class="btn-enable" onclick="disableAccount(\'' +
448
+ jsString(a.email) +
449
+ "')\">Disable</button>"
450
+ : "") +
451
+ (a.status !== "flagged"
452
+ ? '<button class="btn-enable" onclick="quarantineAccount(\'' +
453
+ jsString(a.email) +
454
+ "')\">Quarantine</button>"
455
+ : "") +
456
+ (a.status === "flagged" || a.status === "disabled"
457
+ ? '<button class="btn-enable" onclick="restoreAccount(\'' +
458
+ jsString(a.email) +
459
+ "')\">Restore</button>"
460
+ : "") +
461
+ '<button class="btn-enable" onclick="setAccountFreshWindowOverride(\'' +
462
+ jsString(a.email) +
463
+ "', " +
464
+ !a.allowFreshWindowStartsOverride +
465
+ ')">' +
466
+ (a.allowFreshWindowStartsOverride
467
+ ? "Use Global Fresh Policy"
468
+ : "Allow Fresh On This Account") +
469
+ "</button>" +
470
+ ((a.quota || []).some(function (q) {
471
+ return isIdleForKickstart(q, Date.now());
472
+ })
473
+ ? '<button class="btn-enable" onclick="kickstartAllTimers(\'' +
474
+ jsString(a.email) +
475
+ "'\")>Start Idle Timers</button>"
476
+ : "") +
477
+ '<button class="btn-enable" style="border-color:var(--red);color:var(--red)" onclick="confirmRemoveAccount(\'' +
478
+ jsString(a.email) +
479
+ "')\">Remove</button>" +
480
+ "</div>" +
481
+ (isCooldown && cooldownPercent > 0
482
+ ? '<div class="cooldown-bar" style="width:' +
483
+ cooldownPercent +
484
+ '%"></div>'
485
+ : "") +
486
+ "</div>"
487
+ );
488
+ })
489
+ .join("");
490
+ }
491
+
492
+ // ── List View ─────────────────────────────────────────────────────────────
493
+ var CURRENT_VIEW = "grid";
494
+ var LIST_SORT = "requests";
495
+ var LIST_SORT_DIR = -1; // -1 = desc, 1 = asc
496
+
497
+ function switchView(view) {
498
+ CURRENT_VIEW = view;
499
+ document.getElementById("viewTabGrid").className =
500
+ "view-tab" + (view === "grid" ? " active" : "");
501
+ document.getElementById("viewTabList").className =
502
+ "view-tab" + (view === "list" ? " active" : "");
503
+ document.getElementById("accounts").style.display =
504
+ view === "grid" ? "" : "none";
505
+ document.getElementById("listPanel").style.display =
506
+ view === "list" ? "" : "none";
507
+ if (view === "list" && window.__lastData) renderListView();
508
+ }
509
+
510
+ function setListSort(col) {
511
+ if (LIST_SORT === col) {
512
+ LIST_SORT_DIR = -LIST_SORT_DIR;
513
+ } else {
514
+ LIST_SORT = col;
515
+ LIST_SORT_DIR = -1;
516
+ }
517
+ ["requests", "quota", "tokens", "status"].forEach(function (c) {
518
+ var btn = document.getElementById("lsort-" + c);
519
+ if (btn)
520
+ btn.className = "list-sort-btn" + (c === LIST_SORT ? " active" : "");
521
+ });
522
+ renderListView();
523
+ }
524
+
525
+ function renderListView() {
526
+ if (!window.__lastData) return;
527
+ var data = window.__lastData;
528
+ var wrap = document.getElementById("listTableWrap");
529
+ var query = (
530
+ (document.getElementById("listSearch") || {}).value || ""
531
+ ).toLowerCase();
532
+
533
+ var rows = data.accounts.slice();
534
+
535
+ // Filter by search
536
+ if (query) {
537
+ rows = rows.filter(function (a) {
538
+ return (
539
+ (a.label || "").toLowerCase().indexOf(query) !== -1 ||
540
+ (a.email || "").toLowerCase().indexOf(query) !== -1 ||
541
+ (a.status || "").toLowerCase().indexOf(query) !== -1
542
+ );
543
+ });
544
+ }
545
+
546
+ // Aggregate token totals per account (from tokensByAccount in usage data if present,
547
+ // otherwise fall back to account-level totalTokens if server exposes it)
548
+ var tokensByAccount = {};
549
+ var tokenUsage = data.tokenUsage || {};
550
+ ["minutes", "hours", "days", "months"].forEach(function (tier) {
551
+ (tokenUsage[tier] || []).forEach(function (b) {
552
+ if (!b.byAccount) return;
553
+ Object.keys(b.byAccount).forEach(function (acct) {
554
+ var d = b.byAccount[acct] || {};
555
+ if (!tokensByAccount[acct])
556
+ tokensByAccount[acct] = { input: 0, output: 0 };
557
+ tokensByAccount[acct].input += d.inputTokens || 0;
558
+ tokensByAccount[acct].output += d.outputTokens || 0;
559
+ });
560
+ });
561
+ });
562
+
563
+ // Fallback: use per-account token fields exposed directly on the account object
564
+ rows.forEach(function (a) {
565
+ if (
566
+ !tokensByAccount[a.email] &&
567
+ (a.totalInputTokens || a.totalOutputTokens)
568
+ ) {
569
+ tokensByAccount[a.email] = {
570
+ input: a.totalInputTokens || 0,
571
+ output: a.totalOutputTokens || 0,
572
+ };
573
+ }
574
+ });
575
+
576
+ // Sort
577
+ rows.sort(function (a, b) {
578
+ var av, bv;
579
+ if (LIST_SORT === "requests") {
580
+ av = a.totalRequests || 0;
581
+ bv = b.totalRequests || 0;
582
+ } else if (LIST_SORT === "quota") {
583
+ av =
584
+ a.quota && a.quota.length
585
+ ? a.quota.reduce(function (s, q) {
586
+ return s + q.percentRemaining;
587
+ }, 0) / a.quota.length
588
+ : -1;
589
+ bv =
590
+ b.quota && b.quota.length
591
+ ? b.quota.reduce(function (s, q) {
592
+ return s + q.percentRemaining;
593
+ }, 0) / b.quota.length
594
+ : -1;
595
+ } else if (LIST_SORT === "tokens") {
596
+ var ta = tokensByAccount[a.email] || { input: 0, output: 0 };
597
+ var tb = tokensByAccount[b.email] || { input: 0, output: 0 };
598
+ av = ta.input + ta.output;
599
+ bv = tb.input + tb.output;
600
+ } else if (LIST_SORT === "status") {
601
+ var statusOrder = {
602
+ active: 0,
603
+ ready: 1,
604
+ cooldown: 2,
605
+ exhausted: 3,
606
+ error: 4,
607
+ disabled: 5,
608
+ flagged: 6,
609
+ };
610
+ av = statusOrder[a.status] !== undefined ? statusOrder[a.status] : 9;
611
+ bv = statusOrder[b.status] !== undefined ? statusOrder[b.status] : 9;
612
+ } else {
613
+ av = 0;
614
+ bv = 0;
615
+ }
616
+ if (av < bv) return LIST_SORT_DIR;
617
+ if (av > bv) return -LIST_SORT_DIR;
618
+ return 0;
619
+ });
620
+
621
+ if (rows.length === 0) {
622
+ wrap.innerHTML =
623
+ '<div class="list-empty">No accounts match the filter.</div>';
624
+ return;
625
+ }
626
+
627
+ var arrowFor = function (col) {
628
+ if (LIST_SORT !== col) return '<span class="sort-arrow">↕</span>';
629
+ return (
630
+ '<span class="sort-arrow">' +
631
+ (LIST_SORT_DIR === -1 ? "↓" : "↑") +
632
+ "</span>"
633
+ );
634
+ };
635
+
636
+ var html =
637
+ '<table class="list-table"><thead><tr>' +
638
+ "<th>Account</th>" +
639
+ '<th onclick="setListSort(&apos;status&apos;)" class="' +
640
+ (LIST_SORT === "status" ? "sort-active" : "") +
641
+ '">Status' +
642
+ arrowFor("status") +
643
+ "</th>" +
644
+ '<th onclick="setListSort(&apos;requests&apos;)" class="' +
645
+ (LIST_SORT === "requests" ? "sort-active" : "") +
646
+ '">Total Reqs' +
647
+ arrowFor("requests") +
648
+ "</th>" +
649
+ "<th>This Rotation</th>" +
650
+ '<th onclick="setListSort(&apos;quota&apos;)" class="' +
651
+ (LIST_SORT === "quota" ? "sort-active" : "") +
652
+ '">Avg Quota' +
653
+ arrowFor("quota") +
654
+ "</th>" +
655
+ '<th onclick="setListSort(&apos;tokens&apos;)" class="' +
656
+ (LIST_SORT === "tokens" ? "sort-active" : "") +
657
+ '">Tokens (in/out)' +
658
+ arrowFor("tokens") +
659
+ "</th>" +
660
+ "<th>Last Used</th>" +
661
+ "</tr></thead><tbody>";
662
+
663
+ rows.forEach(function (a) {
664
+ var avgQuota =
665
+ a.quota && a.quota.length > 0
666
+ ? Math.round(
667
+ a.quota.reduce(function (s, q) {
668
+ return s + q.percentRemaining;
669
+ }, 0) / a.quota.length,
670
+ )
671
+ : null;
672
+ var quotaColor =
673
+ avgQuota === null
674
+ ? "var(--text-dim)"
675
+ : avgQuota > 50
676
+ ? "var(--green)"
677
+ : avgQuota > 20
678
+ ? "var(--yellow)"
679
+ : "var(--red)";
680
+
681
+ var statusColors = {
682
+ active: "var(--green)",
683
+ ready: "var(--text-dim)",
684
+ cooldown: "var(--yellow)",
685
+ exhausted: "var(--red)",
686
+ error: "var(--orange)",
687
+ disabled: "#888",
688
+ flagged: "#ff4444",
689
+ };
690
+ var statusColor = statusColors[a.status] || "var(--text-dim)";
691
+
692
+ var ta = tokensByAccount[a.email] || { input: 0, output: 0 };
693
+ var totalTokens = ta.input + ta.output;
694
+
695
+ var lastUsed = a.lastUsed ? formatTime(a.lastUsed) : "--";
696
+
697
+ var quotaCell =
698
+ avgQuota === null
699
+ ? '<span style="color:var(--text-dim)">--</span>'
700
+ : '<div class="list-quota-bar">' +
701
+ '<div class="list-quota-bar-bg"><div class="list-quota-bar-fill" style="width:' +
702
+ avgQuota +
703
+ "%;background:" +
704
+ quotaColor +
705
+ '"></div></div>' +
706
+ '<span style="font-family:JetBrains Mono,monospace;font-size:11px;color:' +
707
+ quotaColor +
708
+ '">' +
709
+ avgQuota +
710
+ "%</span>" +
711
+ "</div>";
712
+
713
+ var tokensCell =
714
+ totalTokens > 0
715
+ ? '<span style="font-family:JetBrains Mono,monospace">' +
716
+ formatTokenCount(ta.input) +
717
+ " / " +
718
+ formatTokenCount(ta.output) +
719
+ "</span>"
720
+ : '<span style="color:var(--text-dim)">--</span>';
721
+
722
+ html +=
723
+ '<tr class="list-row" onclick="jumpToAccount(&apos;' +
724
+ jsString(a.email) +
725
+ '&apos;)">' +
726
+ "<td>" +
727
+ '<div class="list-row-label">' +
728
+ escapeHtml(maskText(a.label)) +
729
+ "</div>" +
730
+ '<div class="list-row-email">' +
731
+ escapeHtml(maskEmail(a.email)) +
732
+ "</div>" +
733
+ "</td>" +
734
+ '<td><span style="color:' +
735
+ statusColor +
736
+ ';font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:0.4px">' +
737
+ escapeHtml(a.status) +
738
+ "</span></td>" +
739
+ '<td style="font-family:JetBrains Mono,monospace;font-weight:700">' +
740
+ (a.totalRequests || 0) +
741
+ "</td>" +
742
+ '<td style="font-family:JetBrains Mono,monospace;color:var(--text-dim)">' +
743
+ (a.requestsSinceRotation || 0) +
744
+ "</td>" +
745
+ "<td>" +
746
+ quotaCell +
747
+ "</td>" +
748
+ "<td>" +
749
+ tokensCell +
750
+ "</td>" +
751
+ '<td style="font-family:JetBrains Mono,monospace;font-size:11px;color:var(--text-dim)">' +
752
+ lastUsed +
753
+ "</td>" +
754
+ "</tr>";
755
+ });
756
+
757
+ html += "</tbody></table>";
758
+ wrap.innerHTML = html;
759
+ }
760
+
761
+ function jumpToAccount(email) {
762
+ // Switch to grid first
763
+ switchView("grid");
764
+ setTimeout(function () {
765
+ var target = document.querySelector(
766
+ '[data-account-email="' + email.replace(/"/g, '\"') + '"]',
767
+ );
768
+ if (target) {
769
+ target.scrollIntoView({ behavior: "smooth", block: "center" });
770
+ target.classList.add("list-highlight");
771
+ setTimeout(function () {
772
+ target.classList.remove("list-highlight");
773
+ }, 2000);
774
+ }
775
+ }, 80);
776
+ }
777
+
778
+ function renderHealthPill(label, value) {
779
+ return (
780
+ '<div class="health-pill"><span class="label">' +
781
+ escapeHtml(label) +
782
+ '</span><span class="value">' +
783
+ escapeHtml(value) +
784
+ "</span></div>"
785
+ );
786
+ }
787
+
788
+ function renderAttentionPanel(data) {
789
+ var panel = document.getElementById("attentionPanel");
790
+ var button = document.getElementById("attentionBtn");
791
+ var badge = document.getElementById("attentionBadge");
792
+ var accounts = data.accounts || [];
793
+ var security = data.security || {};
794
+ var routingDiagnostics = data.routingDiagnostics || {};
795
+ var flagged = accounts.filter(function (a) {
796
+ return a.status === "flagged";
797
+ });
798
+ var disabled = accounts.filter(function (a) {
799
+ return a.status === "disabled";
800
+ });
801
+ var errors = accounts.filter(function (a) {
802
+ return a.status === "error";
803
+ });
804
+ var unroutableModels = Object.keys(routingDiagnostics)
805
+ .map(function (modelKey) {
806
+ return routingDiagnostics[modelKey];
807
+ })
808
+ .filter(function (diag) {
809
+ return diag && !diag.selectedEmail;
810
+ });
811
+ var tokenBucketExhausted = accounts.filter(function (a) {
812
+ return (
813
+ a.tokenBucket &&
814
+ a.tokenBucket.enabled &&
815
+ Number(a.tokenBucket.tokens || 0) < 1
816
+ );
817
+ });
818
+ var cooldown = accounts
819
+ .filter(function (a) {
820
+ return a.status === "cooldown";
821
+ })
822
+ .map(function (a) {
823
+ var ts = Object.values(a.cooldownsByModel || {});
824
+ var max = ts.length > 0 ? Math.max.apply(null, ts) : 0;
825
+ return { account: a, remaining: Math.max(0, max - Date.now()) };
826
+ })
827
+ .sort(function (a, b) {
828
+ return a.remaining - b.remaining;
829
+ })
830
+ .slice(0, 4);
831
+ var items = [];
832
+
833
+ if (security.warning) {
834
+ items.push(
835
+ renderAttentionItem(
836
+ "Security warning",
837
+ security.warning,
838
+ [
839
+ "Set PI_ROTATOR_ADMIN_TOKEN to protect dashboard and admin APIs.",
840
+ "For local-only usage, prefer bindHost 127.0.0.1.",
841
+ ],
842
+ "warning",
843
+ ),
844
+ );
845
+ }
846
+
847
+ if (flagged.length > 0) {
848
+ items.push(
849
+ renderAttentionItem(
850
+ "Flagged by provider",
851
+ flagged.length +
852
+ " account(s) are quarantined after a provider enforcement signal. Keep them out of rotation until the provider explicitly restores access.",
853
+ flagged.map(function (a) {
854
+ return maskText(a.label);
855
+ }),
856
+ "flagged",
857
+ ),
858
+ );
859
+ }
860
+ if (cooldown.length > 0) {
861
+ items.push(
862
+ renderAttentionItem(
863
+ "Cooling down",
864
+ "These are the next accounts expected to come back. Routing waits for their retry windows instead of forcing traffic into them.",
865
+ cooldown.map(function (c) {
866
+ return maskText(c.account.label) + " " + formatDuration(c.remaining);
867
+ }),
868
+ "cooldown",
869
+ ),
870
+ );
871
+ }
872
+ if (disabled.length > 0) {
873
+ items.push(
874
+ renderAttentionItem(
875
+ "Disabled accounts",
876
+ "These accounts hit repeated operational errors and were taken out of service. Re-enable only after the underlying problem is fixed.",
877
+ disabled.map(function (a) {
878
+ return maskText(a.label);
879
+ }),
880
+ "disabled",
881
+ ),
882
+ );
883
+ }
884
+ if (errors.length > 0) {
885
+ items.push(
886
+ renderAttentionItem(
887
+ "Recent errors",
888
+ "These accounts are still visible but currently erroring. Review the per-account error details below before they escalate to disabled.",
889
+ errors.map(function (a) {
890
+ return maskText(a.label);
891
+ }),
892
+ "error",
893
+ ),
894
+ );
895
+ }
896
+ if (unroutableModels.length > 0) {
897
+ items.push(
898
+ renderAttentionItem(
899
+ "No routing candidate",
900
+ "These models currently have no selected account. The inspector shows which checks are blocking routing right now.",
901
+ unroutableModels.map(function (diag) {
902
+ return diag.modelKey + ": " + (diag.reason || "No reason available");
903
+ }),
904
+ "warning",
905
+ ),
906
+ );
907
+ }
908
+ if (tokenBucketExhausted.length > 0) {
909
+ items.push(
910
+ renderAttentionItem(
911
+ "Token bucket exhausted",
912
+ "Hybrid routing is holding these accounts briefly to avoid hammering the provider before the local refill window resets.",
913
+ tokenBucketExhausted.map(function (a) {
914
+ return (
915
+ maskText(a.label) +
916
+ " " +
917
+ formatDuration(a.tokenBucket.nextRefillInMs || 0)
918
+ );
919
+ }),
920
+ "cooldown",
921
+ ),
922
+ );
923
+ }
924
+
925
+ if (items.length === 0) {
926
+ panel.innerHTML =
927
+ '<div class="modal-empty">No operator action items right now.</div>';
928
+ badge.style.display = "none";
929
+ button.classList.remove("has-items");
930
+ return;
931
+ }
932
+
933
+ panel.innerHTML =
934
+ '<div class="operator-list" style="display:flex;flex-direction:column;gap:12px;">' +
935
+ items.join("") +
936
+ "</div>";
937
+ badge.style.display = "inline-flex";
938
+ badge.textContent = String(items.length);
939
+ button.classList.add("has-items");
940
+ }
941
+
942
+ function renderAttentionItem(title, description, tags, type) {
943
+ var icon = "";
944
+ var colorClass = "";
945
+
946
+ if (type === "flagged") {
947
+ icon =
948
+ '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>';
949
+ colorClass = "operator-red";
950
+ } else if (type === "cooldown") {
951
+ icon =
952
+ '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/></svg>';
953
+ colorClass = "operator-yellow";
954
+ } else if (type === "disabled") {
955
+ icon =
956
+ '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z"/></svg>';
957
+ colorClass = "operator-gray";
958
+ } else {
959
+ icon =
960
+ '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></svg>';
961
+ colorClass = "operator-orange";
962
+ }
963
+
964
+ var tagsHtml = tags
965
+ .map(function (t) {
966
+ return '<span class="operator-tag">' + escapeHtml(t) + "</span>";
967
+ })
968
+ .join("");
969
+
970
+ return (
971
+ '<div class="operator-item ' +
972
+ colorClass +
973
+ '">' +
974
+ '<div class="operator-icon">' +
975
+ icon +
976
+ "</div>" +
977
+ '<div class="operator-content">' +
978
+ "<strong>" +
979
+ escapeHtml(title) +
980
+ "</strong>" +
981
+ "<p>" +
982
+ escapeHtml(description) +
983
+ "</p>" +
984
+ '<div class="operator-tags">' +
985
+ tagsHtml +
986
+ "</div>" +
987
+ "</div>" +
988
+ "</div>"
989
+ );
990
+ }
991
+
992
+ var TOKEN_MODEL_COLORS = {
993
+ "claude-opus-4-6-thinking": "#ef4444", // Rojo
994
+ "claude-sonnet-4-6": "#f97316", // Naranja
995
+ "gemini-3.1-pro-high": "#3b82f6", // Azul
996
+ "gemini-3.1-pro-low": "#38bdf8", // Celeste
997
+ "gemini-3-flash": "#4ade80", // Verde
998
+ "gemini-3.5-flash-low": "#a3e635", // Lime (legacy alias)
999
+ "gemini-3.5-flash-medium": "#a3e635", // Lime
1000
+ "gemini-3.5-flash-high": "#84cc16", // Darker Lime
1001
+ "gemini-3.5-flash": "#84cc16",
1002
+ "gemini-3.1-pro": "#fb923c", // Fallback genérico
1003
+ "gpt-oss-120b-medium": "#a855f7", // Purpura
1004
+ __other__: "#6b7280",
1005
+ };
1006
+
1007
+ function getModelColor(model) {
1008
+ return TOKEN_MODEL_COLORS[model] || TOKEN_MODEL_COLORS["__other__"];
1009
+ }
1010
+
1011
+ function formatTokenCount(n) {
1012
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
1013
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
1014
+ return String(n);
1015
+ }
1016
+
1017
+ // Pricing per 1M tokens (USD) — mirrors server-side MODEL_PRICING in types.ts
1018
+ var MODEL_PRICING_CLIENT = {
1019
+ "claude-opus-4-6-thinking": { input: 5.0, output: 25.0 },
1020
+ "claude-sonnet-4-6": { input: 3.0, output: 15.0 },
1021
+ "gemini-3.1-pro": { input: 2.0, output: 12.0 },
1022
+ "gemini-3.1-pro-low": { input: 2.0, output: 12.0 },
1023
+ "gemini-3.1-pro-high": { input: 2.0, output: 12.0 },
1024
+ "gemini-3-flash": { input: 0.5, output: 3.0 },
1025
+ "gemini-3.5-flash": { input: 1.5, output: 9.0 },
1026
+ "gemini-3.5-flash-low": { input: 1.5, output: 9.0 },
1027
+ "gemini-3.5-flash-medium": { input: 1.5, output: 9.0 },
1028
+ "gemini-3.5-flash-high": { input: 1.5, output: 9.0 },
1029
+ "gpt-oss-120b-medium": { input: 2.0, output: 10.0 },
1030
+ };
1031
+
1032
+ function calcSavingsFromBuckets(buckets) {
1033
+ var byModel = {};
1034
+ var totalUsd = 0;
1035
+ (buckets || []).forEach(function (b) {
1036
+ Object.keys(b.byModel || {}).forEach(function (m) {
1037
+ var d = b.byModel[m];
1038
+ var p = MODEL_PRICING_CLIENT[m];
1039
+ if (!p) return;
1040
+ var usd =
1041
+ (d.inputTokens / 1e6) * p.input + (d.outputTokens / 1e6) * p.output;
1042
+ if (!byModel[m]) byModel[m] = { inputUsd: 0, outputUsd: 0, totalUsd: 0 };
1043
+ byModel[m].inputUsd += (d.inputTokens / 1e6) * p.input;
1044
+ byModel[m].outputUsd += (d.outputTokens / 1e6) * p.output;
1045
+ byModel[m].totalUsd += usd;
1046
+ totalUsd += usd;
1047
+ });
1048
+ });
1049
+ return { totalUsd: totalUsd, byModel: byModel };
1050
+ }
1051
+
1052
+ window.__tokenView = "1h";
1053
+
1054
+ function exportData(format) {
1055
+ if (!window.__lastData || !window.__lastData.tokenUsage) return;
1056
+ var usage = window.__lastData.tokenUsage;
1057
+
1058
+ if (format === "json") {
1059
+ var dataStr =
1060
+ "data:text/json;charset=utf-8," +
1061
+ encodeURIComponent(JSON.stringify(usage, null, 2));
1062
+ var a = document.createElement("a");
1063
+ a.href = dataStr;
1064
+ a.download = "rotator-token-usage.json";
1065
+ a.click();
1066
+ } else if (format === "csv") {
1067
+ var csv = "Tier,Period,Model,InputTokens,OutputTokens,Requests\n";
1068
+ ["months", "days", "hours", "minutes"].forEach(function (tier) {
1069
+ (usage[tier] || []).forEach(function (b) {
1070
+ if (!b.byModel) return;
1071
+ Object.keys(b.byModel).forEach(function (m) {
1072
+ var d = b.byModel[m];
1073
+ csv +=
1074
+ tier +
1075
+ "," +
1076
+ b.period +
1077
+ "," +
1078
+ m +
1079
+ "," +
1080
+ d.inputTokens +
1081
+ "," +
1082
+ d.outputTokens +
1083
+ "," +
1084
+ d.requests +
1085
+ "\n";
1086
+ });
1087
+ });
1088
+ });
1089
+ var dataStrCSV = "data:text/csv;charset=utf-8," + encodeURIComponent(csv);
1090
+ var a2 = document.createElement("a");
1091
+ a2.href = dataStrCSV;
1092
+ a2.download = "rotator-token-usage.csv";
1093
+ a2.click();
1094
+ }
1095
+ }
1096
+
1097
+ function setTokenView(view) {
1098
+ window.__tokenView = view;
1099
+ refresh();
1100
+ }
1101
+
1102
+ function formatBucketLabel(period, view) {
1103
+ try {
1104
+ if (view.endsWith("h") && view !== "1d") {
1105
+ var d;
1106
+ if (period.length === 16) d = new Date(period + ":00Z");
1107
+ else d = new Date(period);
1108
+ if (!isNaN(d.getTime())) {
1109
+ if (view === "1h") return ":" + String(d.getMinutes()).padStart(2, "0");
1110
+ return (
1111
+ String(d.getHours()).padStart(2, "0") +
1112
+ ":" +
1113
+ String(d.getMinutes()).padStart(2, "0")
1114
+ );
1115
+ }
1116
+ }
1117
+ if (view === "1d") return period.slice(11, 13) + "h";
1118
+ if (view === "7d" || view === "1m") return period.slice(5, 10);
1119
+ } catch (e) {}
1120
+ return period;
1121
+ }
1122
+
1123
+ function renderTokenChart(tokenUsage) {
1124
+ var panel = document.getElementById("tokenUsagePanel");
1125
+ var chart = document.getElementById("tokenChart");
1126
+ var legend = document.getElementById("tokenLegend");
1127
+ var totals = document.getElementById("tokenTotals");
1128
+ var view = window.__tokenView || "1h";
1129
+
1130
+ // Highlight active button
1131
+ ["1h", "2h", "4h", "8h", "12h", "1d", "7d", "1m"].forEach(function (v) {
1132
+ var btn = document.getElementById("tbtn-" + v);
1133
+ if (btn)
1134
+ btn.className = "btn-secondary btn-sm" + (v === view ? " active" : "");
1135
+ });
1136
+
1137
+ if (!tokenUsage) {
1138
+ panel.style.display = "none";
1139
+ return;
1140
+ }
1141
+
1142
+ // Helper: merge buckets into a map by a grouping key
1143
+ function mergeBucketsBy(sources, keyFn, limit) {
1144
+ var map = {};
1145
+ sources.forEach(function (b) {
1146
+ var key = keyFn(b.period);
1147
+ if (!key) return;
1148
+ if (!map[key])
1149
+ map[key] = {
1150
+ period: key,
1151
+ inputTokens: 0,
1152
+ outputTokens: 0,
1153
+ requests: 0,
1154
+ byModel: {},
1155
+ };
1156
+ map[key].inputTokens += b.inputTokens;
1157
+ map[key].outputTokens += b.outputTokens;
1158
+ map[key].requests += b.requests;
1159
+ Object.keys(b.byModel || {}).forEach(function (m) {
1160
+ if (!map[key].byModel[m])
1161
+ map[key].byModel[m] = {
1162
+ inputTokens: 0,
1163
+ outputTokens: 0,
1164
+ requests: 0,
1165
+ };
1166
+ map[key].byModel[m].inputTokens +=
1167
+ (b.byModel[m] || {}).inputTokens || 0;
1168
+ map[key].byModel[m].outputTokens +=
1169
+ (b.byModel[m] || {}).outputTokens || 0;
1170
+ map[key].byModel[m].requests += (b.byModel[m] || {}).requests || 0;
1171
+ });
1172
+ });
1173
+ return Object.keys(map)
1174
+ .sort()
1175
+ .map(function (k) {
1176
+ return map[k];
1177
+ })
1178
+ .slice(-limit);
1179
+ }
1180
+
1181
+ function getLocalKey(periodStr, type) {
1182
+ try {
1183
+ var d;
1184
+ if (periodStr.length === 10) d = new Date(periodStr + "T00:00:00Z");
1185
+ else if (periodStr.length === 13) d = new Date(periodStr + ":00:00Z");
1186
+ else if (periodStr.length === 16) d = new Date(periodStr + ":00Z");
1187
+ else d = new Date(periodStr);
1188
+ if (isNaN(d.getTime())) return periodStr;
1189
+
1190
+ var y = d.getFullYear();
1191
+ var mo = String(d.getMonth() + 1).padStart(2, "0");
1192
+ var da = String(d.getDate()).padStart(2, "0");
1193
+ var h = String(d.getHours()).padStart(2, "0");
1194
+ var mi = d.getMinutes();
1195
+
1196
+ if (type === "day") return y + "-" + mo + "-" + da;
1197
+ if (type === "hour") return y + "-" + mo + "-" + da + "T" + h;
1198
+ if (type === "5min")
1199
+ return (
1200
+ y +
1201
+ "-" +
1202
+ mo +
1203
+ "-" +
1204
+ da +
1205
+ "T" +
1206
+ h +
1207
+ ":" +
1208
+ String(Math.floor(mi / 5) * 5).padStart(2, "0")
1209
+ );
1210
+ if (type === "4min")
1211
+ return (
1212
+ y +
1213
+ "-" +
1214
+ mo +
1215
+ "-" +
1216
+ da +
1217
+ "T" +
1218
+ h +
1219
+ ":" +
1220
+ String(Math.floor(mi / 4) * 4).padStart(2, "0")
1221
+ );
1222
+ if (type === "2min")
1223
+ return (
1224
+ y +
1225
+ "-" +
1226
+ mo +
1227
+ "-" +
1228
+ da +
1229
+ "T" +
1230
+ h +
1231
+ ":" +
1232
+ String(Math.floor(mi / 2) * 2).padStart(2, "0")
1233
+ );
1234
+ } catch (e) {}
1235
+ return periodStr;
1236
+ }
1237
+
1238
+ // Pad buckets with zeroes up to current time.
1239
+ // keyFn: optional function(isoString) -> key used for the fill loop.
1240
+ // When provided, dataMap is keyed by b.period directly (already normalized).
1241
+ // When omitted, type determines both the dataMap key and the fill key.
1242
+ function padBuckets(data, view, keyFn) {
1243
+ if (!data) data = [];
1244
+ var now = new Date();
1245
+ var stepMs = 60000;
1246
+ var count = 60;
1247
+ var type = "raw";
1248
+
1249
+ if (view === "1h") {
1250
+ stepMs = 60000;
1251
+ count = 60;
1252
+ } else if (view === "2h") {
1253
+ stepMs = 60000;
1254
+ count = 120;
1255
+ } else if (view === "4h") {
1256
+ stepMs = 120000;
1257
+ count = 120;
1258
+ type = "2min";
1259
+ } else if (view === "8h") {
1260
+ stepMs = 240000;
1261
+ count = 120;
1262
+ type = "4min";
1263
+ } else if (view === "12h") {
1264
+ stepMs = 300000;
1265
+ count = 144;
1266
+ type = "5min";
1267
+ } else if (view === "1d") {
1268
+ stepMs = 3600000;
1269
+ count = 24;
1270
+ type = "hour";
1271
+ } else if (view === "7d") {
1272
+ stepMs = 86400000;
1273
+ count = 7;
1274
+ type = "day";
1275
+ } else {
1276
+ stepMs = 86400000;
1277
+ count = 30;
1278
+ type = "day";
1279
+ }
1280
+
1281
+ var dataMap = {};
1282
+ if (keyFn) {
1283
+ // Data already normalized by mergeBucketsBy — use period as-is
1284
+ data.forEach(function (b) {
1285
+ dataMap[b.period] = b;
1286
+ });
1287
+ } else {
1288
+ data.forEach(function (b) {
1289
+ var k = type === "raw" ? b.period : getLocalKey(b.period, type);
1290
+ dataMap[k] = b;
1291
+ });
1292
+ }
1293
+
1294
+ var result = [];
1295
+ for (var i = count - 1; i >= 0; i--) {
1296
+ var d = new Date(now.getTime() - i * stepMs);
1297
+ var k = keyFn
1298
+ ? keyFn(d.toISOString())
1299
+ : type === "raw"
1300
+ ? d.toISOString().slice(0, 16)
1301
+ : getLocalKey(d.toISOString(), type);
1302
+ result.push(
1303
+ dataMap[k] || {
1304
+ period: k,
1305
+ inputTokens: 0,
1306
+ outputTokens: 0,
1307
+ requests: 0,
1308
+ byModel: {},
1309
+ },
1310
+ );
1311
+ }
1312
+ return result;
1313
+ }
1314
+
1315
+ var allTiers = (tokenUsage.months || [])
1316
+ .concat(tokenUsage.days || [])
1317
+ .concat(tokenUsage.hours || [])
1318
+ .concat(tokenUsage.minutes || []);
1319
+
1320
+ // Pick tier based on view
1321
+ var buckets;
1322
+ if (view === "1h") {
1323
+ buckets = padBuckets(tokenUsage.minutes || [], view);
1324
+ } else if (view === "2h") {
1325
+ buckets = padBuckets(tokenUsage.minutes || [], view);
1326
+ } else if (view === "4h") {
1327
+ var src4h = (tokenUsage.hours || []).concat(tokenUsage.minutes || []);
1328
+ var kfn4h = function (p) {
1329
+ return getLocalKey(p, "2min");
1330
+ };
1331
+ buckets = padBuckets(mergeBucketsBy(src4h, kfn4h, 120), view, kfn4h);
1332
+ } else if (view === "8h") {
1333
+ var src8h = (tokenUsage.hours || []).concat(tokenUsage.minutes || []);
1334
+ var kfn8h = function (p) {
1335
+ return getLocalKey(p, "4min");
1336
+ };
1337
+ buckets = padBuckets(mergeBucketsBy(src8h, kfn8h, 120), view, kfn8h);
1338
+ } else if (view === "12h") {
1339
+ var src12h = (tokenUsage.hours || []).concat(tokenUsage.minutes || []);
1340
+ var kfn12h = function (p) {
1341
+ return getLocalKey(p, "5min");
1342
+ };
1343
+ buckets = padBuckets(mergeBucketsBy(src12h, kfn12h, 144), view, kfn12h);
1344
+ } else if (view === "1d") {
1345
+ var kfn1d = function (p) {
1346
+ return getLocalKey(p, "hour");
1347
+ };
1348
+ buckets = padBuckets(
1349
+ mergeBucketsBy(
1350
+ (tokenUsage.hours || []).concat(tokenUsage.minutes || []),
1351
+ kfn1d,
1352
+ 24,
1353
+ ),
1354
+ view,
1355
+ kfn1d,
1356
+ );
1357
+ } else if (view === "7d") {
1358
+ buckets = padBuckets(
1359
+ mergeBucketsBy(
1360
+ allTiers,
1361
+ function (p) {
1362
+ return getLocalKey(p, "day");
1363
+ },
1364
+ 7,
1365
+ ),
1366
+ view,
1367
+ );
1368
+ } else {
1369
+ buckets = padBuckets(
1370
+ mergeBucketsBy(
1371
+ allTiers,
1372
+ function (p) {
1373
+ return getLocalKey(p, "day");
1374
+ },
1375
+ 30,
1376
+ ),
1377
+ view,
1378
+ );
1379
+ }
1380
+
1381
+ if (!buckets || buckets.length === 0) {
1382
+ chart.innerHTML =
1383
+ '<div style="color:var(--text-dim);padding:20px;text-align:center">No data for this range yet</div>';
1384
+ totals.innerHTML = "";
1385
+ legend.innerHTML = "";
1386
+ return;
1387
+ }
1388
+ panel.style.display = "";
1389
+
1390
+ // Collect all models
1391
+ var allModels = {};
1392
+ buckets.forEach(function (b) {
1393
+ Object.keys(b.byModel || {}).forEach(function (m) {
1394
+ allModels[m] = true;
1395
+ });
1396
+ });
1397
+ var models = Object.keys(allModels).sort();
1398
+
1399
+ // Max tokens for Y scale
1400
+ var maxTokens = 0;
1401
+ buckets.forEach(function (b) {
1402
+ var total = b.inputTokens + b.outputTokens;
1403
+ if (total > maxTokens) maxTokens = total;
1404
+ });
1405
+ if (maxTokens === 0) maxTokens = 1;
1406
+
1407
+ var chartWidth = chart.clientWidth || 800;
1408
+ var minSvgWidth = buckets.length * 16 + 40;
1409
+ var svgWidth = Math.max(chartWidth, minSvgWidth);
1410
+ var availableWidth = svgWidth - 50;
1411
+ var step = availableWidth / Math.max(1, buckets.length);
1412
+ var barWidth = Math.min(36, step * 0.8);
1413
+ var chartHeight = 140;
1414
+
1415
+ var bars = "";
1416
+ buckets.forEach(function (b, i) {
1417
+ var x = 40 + i * step + (step - barWidth) / 2;
1418
+
1419
+ // Stack by model
1420
+ var yOffset = chartHeight;
1421
+ models.forEach(function (model) {
1422
+ var md = (b.byModel || {})[model];
1423
+ if (!md) return;
1424
+ var modelTokens = md.inputTokens + md.outputTokens;
1425
+ var segHeight = Math.max(
1426
+ 0,
1427
+ (modelTokens / maxTokens) * (chartHeight - 20),
1428
+ );
1429
+ yOffset -= segHeight;
1430
+ bars +=
1431
+ '<rect x="' +
1432
+ x +
1433
+ '" y="' +
1434
+ yOffset +
1435
+ '" width="' +
1436
+ barWidth +
1437
+ '" height="' +
1438
+ segHeight +
1439
+ '" fill="' +
1440
+ getModelColor(model) +
1441
+ '" rx="2" opacity="0.85"><title>' +
1442
+ model +
1443
+ ": " +
1444
+ formatTokenCount(modelTokens) +
1445
+ " tokens (" +
1446
+ (md.requests || 0) +
1447
+ " reqs)</title></rect>";
1448
+ });
1449
+
1450
+ // X-axis label
1451
+ var lbl = formatBucketLabel(b.period, view);
1452
+ bars +=
1453
+ '<text x="' +
1454
+ (x + barWidth / 2) +
1455
+ '" y="' +
1456
+ (chartHeight + 14) +
1457
+ '" text-anchor="middle" fill="#888" font-size="9" font-family="JetBrains Mono,monospace">' +
1458
+ lbl +
1459
+ "</text>";
1460
+ });
1461
+
1462
+ // Y-axis
1463
+ var yLabels = "";
1464
+ for (var yi = 0; yi <= 3; yi++) {
1465
+ var yVal = (maxTokens / 3) * yi;
1466
+ var yPos = chartHeight - ((chartHeight - 20) / 3) * yi;
1467
+ yLabels +=
1468
+ '<text x="36" y="' +
1469
+ (yPos + 3) +
1470
+ '" text-anchor="end" fill="#666" font-size="9" font-family="JetBrains Mono,monospace">' +
1471
+ formatTokenCount(Math.round(yVal)) +
1472
+ "</text>";
1473
+ yLabels +=
1474
+ '<line x1="38" y1="' +
1475
+ yPos +
1476
+ '" x2="' +
1477
+ svgWidth +
1478
+ '" y2="' +
1479
+ yPos +
1480
+ '" stroke="#333" stroke-dasharray="2,4"/>';
1481
+ }
1482
+
1483
+ chart.innerHTML =
1484
+ '<svg width="' +
1485
+ svgWidth +
1486
+ '" height="' +
1487
+ (chartHeight + 20) +
1488
+ '" style="min-width:100%">' +
1489
+ yLabels +
1490
+ bars +
1491
+ "</svg>";
1492
+
1493
+ var savings = calcSavingsFromBuckets(buckets);
1494
+ var savingsText =
1495
+ savings.totalUsd > 0
1496
+ ? ' · <span style="color:var(--green);font-weight:700">Savings: $' +
1497
+ savings.totalUsd.toFixed(2) +
1498
+ "</span>"
1499
+ : "";
1500
+
1501
+ totals.innerHTML =
1502
+ "In: " +
1503
+ formatTokenCount(tokenUsage.totalInputTokens) +
1504
+ " · Out: " +
1505
+ formatTokenCount(tokenUsage.totalOutputTokens) +
1506
+ " · Reqs: " +
1507
+ tokenUsage.totalRequests +
1508
+ savingsText;
1509
+
1510
+ legend.innerHTML = models
1511
+ .map(function (m) {
1512
+ var modelSavings = (savings.byModel || {})[m];
1513
+ var savingsLabel =
1514
+ modelSavings && modelSavings.totalUsd > 0.01
1515
+ ? ' <span style="color:var(--green)">$' +
1516
+ modelSavings.totalUsd.toFixed(2) +
1517
+ "</span>"
1518
+ : "";
1519
+ return (
1520
+ '<div style="display:flex;align-items:center;gap:4px">' +
1521
+ '<div style="width:10px;height:10px;border-radius:2px;background:' +
1522
+ getModelColor(m) +
1523
+ '"></div>' +
1524
+ '<span style="color:var(--text-dim)">' +
1525
+ m +
1526
+ savingsLabel +
1527
+ "</span></div>"
1528
+ );
1529
+ })
1530
+ .join("");
1531
+ }
1532
+
1533
+ function formatMs(ms) {
1534
+ if (ms >= 60000) return (ms / 60000).toFixed(1) + "m";
1535
+ if (ms >= 1000) return (ms / 1000).toFixed(1) + "s";
1536
+ return ms + "ms";
1537
+ }
1538
+
1539
+ function renderHeatmap(tokenUsage) {
1540
+ var panel = document.getElementById("heatmapPanel");
1541
+ var grid = document.getElementById("heatmapGrid");
1542
+ if (!tokenUsage) {
1543
+ panel.style.display = "none";
1544
+ return;
1545
+ }
1546
+
1547
+ var hours = tokenUsage.hours || [];
1548
+ var minutes = tokenUsage.minutes || [];
1549
+ var now = new Date();
1550
+ var daysCount = 60;
1551
+ var days = [];
1552
+ for (var i = daysCount - 1; i >= 0; i--) {
1553
+ var d = new Date(now);
1554
+ d.setDate(now.getDate() - i);
1555
+ var key =
1556
+ d.getFullYear() +
1557
+ "-" +
1558
+ String(d.getMonth() + 1).padStart(2, "0") +
1559
+ "-" +
1560
+ String(d.getDate()).padStart(2, "0");
1561
+ // show label only for every 7th day to avoid crowding
1562
+ days.push({ key: key, label: i % 7 === 0 ? key.slice(5) : "" });
1563
+ }
1564
+
1565
+ var cellMap = {}; // day|hour -> requests
1566
+ function addBucket(dayKey, hour, reqs) {
1567
+ var k = dayKey + "|" + hour;
1568
+ if (!cellMap[k]) cellMap[k] = 0;
1569
+ cellMap[k] += reqs || 0;
1570
+ }
1571
+
1572
+ function parseLocal(periodStr) {
1573
+ var d;
1574
+ if (periodStr.length === 10) d = new Date(periodStr + "T00:00:00Z");
1575
+ else if (periodStr.length === 13) d = new Date(periodStr + ":00:00Z");
1576
+ else if (periodStr.length === 16) d = new Date(periodStr + ":00Z");
1577
+ else d = new Date(periodStr);
1578
+
1579
+ if (isNaN(d.getTime())) return null;
1580
+ return {
1581
+ dayKey:
1582
+ d.getFullYear() +
1583
+ "-" +
1584
+ String(d.getMonth() + 1).padStart(2, "0") +
1585
+ "-" +
1586
+ String(d.getDate()).padStart(2, "0"),
1587
+ hour: d.getHours(),
1588
+ };
1589
+ }
1590
+
1591
+ hours.forEach(function (b) {
1592
+ if (!b.period) return;
1593
+ var loc = parseLocal(b.period);
1594
+ if (loc) addBucket(loc.dayKey, loc.hour, b.requests);
1595
+ });
1596
+
1597
+ minutes.forEach(function (b) {
1598
+ if (!b.period) return;
1599
+ var loc = parseLocal(b.period);
1600
+ if (loc) addBucket(loc.dayKey, loc.hour, b.requests);
1601
+ });
1602
+
1603
+ var max = 0;
1604
+ for (var h = 0; h < 24; h++) {
1605
+ for (var c = 0; c < days.length; c++) {
1606
+ var v = cellMap[days[c].key + "|" + h] || 0;
1607
+ if (v > max) max = v;
1608
+ }
1609
+ }
1610
+
1611
+ function colorFor(v) {
1612
+ if (v <= 0 || max <= 0) return "rgba(255,255,255,0.05)";
1613
+ var t = v / max;
1614
+ if (t < 0.2) return "rgba(56,189,248,0.25)";
1615
+ if (t < 0.4) return "rgba(56,189,248,0.40)";
1616
+ if (t < 0.6) return "rgba(56,189,248,0.55)";
1617
+ if (t < 0.8) return "rgba(56,189,248,0.72)";
1618
+ return "rgba(56,189,248,0.92)";
1619
+ }
1620
+
1621
+ var html =
1622
+ '<div style="overflow-x:auto"><table style="width:100%;min-width:800px;border-collapse:separate;border-spacing:2px;table-layout:fixed;font-family:JetBrains Mono,monospace;font-size:0.6rem">';
1623
+ html +=
1624
+ '<tr><th style="color:var(--text-dim);padding-right:6px;width:20px">h</th>';
1625
+ days.forEach(function (d) {
1626
+ html +=
1627
+ '<th style="color:var(--text-dim);font-weight:500;text-align:left;white-space:nowrap;overflow:visible">' +
1628
+ d.label +
1629
+ "</th>";
1630
+ });
1631
+ html += "</tr>";
1632
+
1633
+ for (var hour = 0; hour < 24; hour++) {
1634
+ html +=
1635
+ '<tr><td style="color:var(--text-dim);padding-right:6px;text-align:right">' +
1636
+ String(hour).padStart(2, "0") +
1637
+ "</td>";
1638
+ for (var j = 0; j < days.length; j++) {
1639
+ var day = days[j].key;
1640
+ var val = cellMap[day + "|" + hour] || 0;
1641
+ html +=
1642
+ '<td title="' +
1643
+ day +
1644
+ " " +
1645
+ String(hour).padStart(2, "0") +
1646
+ ":00 · " +
1647
+ val +
1648
+ ' req" style="height:14px;border-radius:2px;background:' +
1649
+ colorFor(val) +
1650
+ ';border:1px solid rgba(255,255,255,0.05)"></td>';
1651
+ }
1652
+ html += "</tr>";
1653
+ }
1654
+
1655
+ html += "</table></div>";
1656
+ grid.innerHTML = html;
1657
+ panel.style.display = "";
1658
+ }
1659
+
1660
+ function renderForecastPanel(data) {
1661
+ var panel = document.getElementById("forecastPanel");
1662
+ var grid = document.getElementById("forecastGrid");
1663
+ var accounts = data.accounts || [];
1664
+ var tokenUsage = data.tokenUsage || {};
1665
+
1666
+ // Helper to get capacity weight by tier (empirical request capacity per 100% window)
1667
+ function getTierCapacity(tier) {
1668
+ switch (tier) {
1669
+ case "ultra":
1670
+ return 2000;
1671
+ case "pro":
1672
+ return 1500;
1673
+ case "plus":
1674
+ return 1000;
1675
+ case "free":
1676
+ return 250;
1677
+ default:
1678
+ return 250; // unknown
1679
+ }
1680
+ }
1681
+
1682
+ // Aggregate quota per model across all healthy accounts
1683
+ var modelQuota = {}; // { modelKey: { totalCapacityRemaining, totalCapacityMax, accountCount, entries[] } }
1684
+ accounts.forEach(function (a) {
1685
+ if (a.status === "flagged" || a.status === "disabled") return;
1686
+ var tierCapacity = getTierCapacity(a.tier);
1687
+ (a.quota || []).forEach(function (q) {
1688
+ if (!modelQuota[q.modelKey]) {
1689
+ modelQuota[q.modelKey] = {
1690
+ totalCapacityRemaining: 0,
1691
+ totalCapacityMax: 0,
1692
+ accountCount: 0,
1693
+ entries: [],
1694
+ };
1695
+ }
1696
+ modelQuota[q.modelKey].totalCapacityRemaining +=
1697
+ (q.percentRemaining / 100) * tierCapacity;
1698
+ modelQuota[q.modelKey].totalCapacityMax += tierCapacity;
1699
+ modelQuota[q.modelKey].accountCount += 1;
1700
+ modelQuota[q.modelKey].entries.push(q);
1701
+ });
1702
+ });
1703
+
1704
+ // Calculate burn rate per model from last hour of token usage
1705
+ var minutes = tokenUsage.minutes || [];
1706
+ var now = Date.now();
1707
+ var oneHourAgo = now - 3600000;
1708
+ var recentMinutes = minutes.filter(function (b) {
1709
+ try {
1710
+ return new Date(b.period).getTime() > oneHourAgo;
1711
+ } catch (e) {
1712
+ return false;
1713
+ }
1714
+ });
1715
+ var burnByModel = {}; // requests per hour
1716
+ recentMinutes.forEach(function (b) {
1717
+ Object.keys(b.byModel || {}).forEach(function (m) {
1718
+ if (!burnByModel[m]) burnByModel[m] = 0;
1719
+ burnByModel[m] += (b.byModel[m] || {}).requests || 0;
1720
+ });
1721
+ });
1722
+ // Scale to per-hour if we have less than 60 min of data
1723
+ var minuteSpan = recentMinutes.length || 1;
1724
+ Object.keys(burnByModel).forEach(function (m) {
1725
+ burnByModel[m] = (burnByModel[m] / minuteSpan) * 60; // reqs/hour
1726
+ });
1727
+
1728
+ // Collapse display model burn rates into quota pool keys for forecast
1729
+ // e.g. gemini-3.1-pro-low + gemini-3.1-pro-high → gemini-3.1-pro
1730
+ // e.g. claude-sonnet-4-6 + claude-opus-4-6-thinking → claude-opus-4-6-thinking (quota pool)
1731
+ var burnByPool = {};
1732
+ Object.keys(burnByModel).forEach(function (displayKey) {
1733
+ var poolKey = displayKey;
1734
+ if (displayKey.startsWith("gemini-3.1-pro")) poolKey = "gemini-3.1-pro";
1735
+ if (
1736
+ displayKey.startsWith("gemini-3.5-flash") ||
1737
+ displayKey === "gemini-3-flash"
1738
+ )
1739
+ poolKey = "gemini-3.5-flash";
1740
+ if (displayKey.startsWith("gpt-oss")) poolKey = "gpt-oss-120b-medium";
1741
+ if (displayKey === "claude-sonnet-4-6")
1742
+ poolKey = "claude-opus-4-6-thinking";
1743
+ if (!burnByPool[poolKey]) burnByPool[poolKey] = 0;
1744
+ burnByPool[poolKey] += burnByModel[displayKey];
1745
+ });
1746
+
1747
+ var models = Object.keys(modelQuota).sort();
1748
+ if (models.length === 0) {
1749
+ panel.style.display = "none";
1750
+ return;
1751
+ }
1752
+ panel.style.display = "";
1753
+
1754
+ var html =
1755
+ '<table style="width:100%;border-collapse:collapse;font-family:JetBrains Mono,monospace;font-size:0.8rem">' +
1756
+ '<tr style="color:var(--text-dim);text-align:left">' +
1757
+ '<th style="padding:4px 8px">Model</th>' +
1758
+ '<th style="padding:4px 8px">Pool Quota</th>' +
1759
+ '<th style="padding:4px 8px">Accounts</th>' +
1760
+ '<th style="padding:4px 8px">Burn Rate</th>' +
1761
+ '<th style="padding:4px 8px">Estimate</th>' +
1762
+ '<th style="padding:4px 8px">Next Reset</th>' +
1763
+ "</tr>";
1764
+
1765
+ models.forEach(function (m) {
1766
+ var q = modelQuota[m];
1767
+ var avgQuota =
1768
+ q.totalCapacityMax > 0
1769
+ ? Math.round((q.totalCapacityRemaining / q.totalCapacityMax) * 100)
1770
+ : 0;
1771
+ var color = getModelColor(m);
1772
+ var rate = burnByPool[m] || 0;
1773
+ var rateLabel = rate > 0 ? rate.toFixed(1) + " req/h" : "idle";
1774
+ var displayName = m;
1775
+ if (m === "claude-opus-4-6-thinking") displayName = "claude";
1776
+ if (m === "gemini-3.1-pro") displayName = "gemini-3.1-pro";
1777
+ if (m === "gemini-3.5-flash") displayName = "gemini-3.5-flash";
1778
+
1779
+ var minResetRemaining = null;
1780
+ q.entries.forEach(function (entry) {
1781
+ if (entry.resetTime && entry.timerType !== "fresh") {
1782
+ var remaining = new Date(entry.resetTime).getTime() - now;
1783
+ if (remaining > 0) {
1784
+ var isRolling5h =
1785
+ entry.percentRemaining === 100 &&
1786
+ Math.abs(remaining - 5 * 3600000) < 600000;
1787
+ var isRolling7d =
1788
+ entry.percentRemaining === 100 &&
1789
+ Math.abs(remaining - 7 * 86400000) < 600000;
1790
+ if (!isRolling5h && !isRolling7d) {
1791
+ if (minResetRemaining === null || remaining < minResetRemaining) {
1792
+ minResetRemaining = remaining;
1793
+ }
1794
+ }
1795
+ }
1796
+ }
1797
+ });
1798
+ var nextResetLabel =
1799
+ minResetRemaining !== null ? formatDuration(minResetRemaining) : "--";
1800
+
1801
+ // Estimate: request capacity is weighted by tier capacity
1802
+ var totalCapacity = q.totalCapacityRemaining;
1803
+ var hoursLeft;
1804
+ var estimateLabel;
1805
+ var estimateColor = "var(--text)";
1806
+ if (rate <= 0) {
1807
+ estimateLabel = "∞";
1808
+ estimateColor = "var(--green)";
1809
+ } else {
1810
+ hoursLeft = totalCapacity / rate;
1811
+ if (hoursLeft > 24) {
1812
+ estimateLabel = (hoursLeft / 24).toFixed(1) + "d";
1813
+ estimateColor = "var(--green)";
1814
+ } else if (hoursLeft > 1) {
1815
+ estimateLabel = hoursLeft.toFixed(1) + "h";
1816
+ estimateColor = hoursLeft < 3 ? "var(--yellow)" : "var(--text)";
1817
+ } else {
1818
+ estimateLabel = Math.round(hoursLeft * 60) + "min";
1819
+ estimateColor = "var(--red)";
1820
+ }
1821
+ }
1822
+
1823
+ // Quota bar
1824
+ var barColor =
1825
+ avgQuota > 50
1826
+ ? "var(--green)"
1827
+ : avgQuota > 20
1828
+ ? "var(--yellow)"
1829
+ : "var(--red)";
1830
+ var bar =
1831
+ '<div style="display:flex;align-items:center;gap:6px">' +
1832
+ '<div style="flex:1;height:6px;background:rgba(255,255,255,0.08);border-radius:3px;overflow:hidden">' +
1833
+ '<div style="width:' +
1834
+ avgQuota +
1835
+ "%;height:100%;background:" +
1836
+ barColor +
1837
+ ';border-radius:3px"></div>' +
1838
+ "</div>" +
1839
+ "<span>" +
1840
+ avgQuota +
1841
+ "%</span></div>";
1842
+
1843
+ html +=
1844
+ '<tr style="border-top:1px solid var(--border)">' +
1845
+ '<td style="padding:4px 8px"><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:' +
1846
+ color +
1847
+ ';margin-right:6px"></span>' +
1848
+ displayName +
1849
+ "</td>" +
1850
+ '<td style="padding:4px 8px;min-width:120px">' +
1851
+ bar +
1852
+ "</td>" +
1853
+ '<td style="padding:4px 8px;text-align:center">' +
1854
+ q.accountCount +
1855
+ "</td>" +
1856
+ '<td style="padding:4px 8px">' +
1857
+ rateLabel +
1858
+ "</td>" +
1859
+ '<td style="padding:4px 8px;color:' +
1860
+ estimateColor +
1861
+ ';font-weight:700">' +
1862
+ estimateLabel +
1863
+ "</td>" +
1864
+ '<td style="padding:4px 8px;color:var(--text-dim)">' +
1865
+ nextResetLabel +
1866
+ "</td>" +
1867
+ "</tr>";
1868
+ });
1869
+
1870
+ html += "</table>";
1871
+ grid.innerHTML = html;
1872
+ }
1873
+
1874
+ function renderLatencyPanel(latencyStats) {
1875
+ var panel = document.getElementById("latencyPanel");
1876
+ var grid = document.getElementById("latencyGrid");
1877
+ if (!latencyStats || Object.keys(latencyStats).length === 0) {
1878
+ panel.style.display = "none";
1879
+ return;
1880
+ }
1881
+ panel.style.display = "";
1882
+
1883
+ var models = Object.keys(latencyStats).sort();
1884
+ var html =
1885
+ '<table style="width:100%;border-collapse:collapse;font-family:JetBrains Mono,monospace;font-size:0.8rem">' +
1886
+ '<tr style="color:var(--text-dim);text-align:left">' +
1887
+ '<th style="padding:4px 8px">Model</th>' +
1888
+ '<th style="padding:4px 8px">TTFB p50</th>' +
1889
+ '<th style="padding:4px 8px">TTFB p95</th>' +
1890
+ '<th style="padding:4px 8px">Total p50</th>' +
1891
+ '<th style="padding:4px 8px">Total p95</th>' +
1892
+ '<th style="padding:4px 8px">Samples</th>' +
1893
+ "</tr>";
1894
+
1895
+ models.forEach(function (m) {
1896
+ var s = latencyStats[m];
1897
+ var color = getModelColor(m);
1898
+ html +=
1899
+ '<tr style="border-top:1px solid var(--border)">' +
1900
+ '<td style="padding:4px 8px"><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:' +
1901
+ color +
1902
+ ';margin-right:6px"></span>' +
1903
+ escapeHtml(m) +
1904
+ "</td>" +
1905
+ '<td style="padding:4px 8px">' +
1906
+ formatMs(s.ttfb.p50) +
1907
+ "</td>" +
1908
+ '<td style="padding:4px 8px;color:' +
1909
+ (s.ttfb.p95 > 10000 ? "var(--yellow)" : "var(--text)") +
1910
+ '">' +
1911
+ formatMs(s.ttfb.p95) +
1912
+ "</td>" +
1913
+ '<td style="padding:4px 8px">' +
1914
+ formatMs(s.total.p50) +
1915
+ "</td>" +
1916
+ '<td style="padding:4px 8px;color:' +
1917
+ (s.total.p95 > 30000 ? "var(--yellow)" : "var(--text)") +
1918
+ '">' +
1919
+ formatMs(s.total.p95) +
1920
+ "</td>" +
1921
+ '<td style="padding:4px 8px;color:var(--text-dim)">' +
1922
+ s.count +
1923
+ "</td>" +
1924
+ "</tr>";
1925
+ });
1926
+
1927
+ html += "</table>";
1928
+ grid.innerHTML = html;
1929
+ }
1930
+
1931
+ function renderRequestLog(log) {
1932
+ var panel = document.getElementById("requestLogPanel");
1933
+ var grid = document.getElementById("requestLogGrid");
1934
+ if (!log || log.length === 0) {
1935
+ panel.style.display = "none";
1936
+ return;
1937
+ }
1938
+ panel.style.display = "";
1939
+
1940
+ var fModel = (
1941
+ document.getElementById("logFilterModel").value || ""
1942
+ ).toLowerCase();
1943
+ var fAccount = (
1944
+ document.getElementById("logFilterAccount").value || ""
1945
+ ).toLowerCase();
1946
+ var fStatus = (document.getElementById("logFilterStatus").value || "").trim();
1947
+
1948
+ var filtered = log.filter(function (r) {
1949
+ if (fModel && r.model.toLowerCase().indexOf(fModel) === -1) return false;
1950
+ if (fAccount && r.account.toLowerCase().indexOf(fAccount) === -1)
1951
+ return false;
1952
+ if (fStatus && String(r.statusCode).indexOf(fStatus) === -1) return false;
1953
+ return true;
1954
+ });
1955
+
1956
+ var html =
1957
+ '<table style="width:100%;border-collapse:collapse;font-family:JetBrains Mono,monospace;font-size:0.75rem">' +
1958
+ '<tr style="color:var(--text-dim);text-align:left;position:sticky;top:0;background:var(--card-bg)">' +
1959
+ '<th style="padding:3px 6px">Time</th>' +
1960
+ '<th style="padding:3px 6px">Model</th>' +
1961
+ '<th style="padding:3px 6px">Account</th>' +
1962
+ '<th style="padding:3px 6px">Status</th>' +
1963
+ '<th style="padding:3px 6px">TTFB</th>' +
1964
+ '<th style="padding:3px 6px">Total</th>' +
1965
+ '<th style="padding:3px 6px">Tokens</th>' +
1966
+ "</tr>";
1967
+
1968
+ filtered.forEach(function (r) {
1969
+ var t = new Date(r.timestamp);
1970
+ var time =
1971
+ ("0" + t.getHours()).slice(-2) +
1972
+ ":" +
1973
+ ("0" + t.getMinutes()).slice(-2) +
1974
+ ":" +
1975
+ ("0" + t.getSeconds()).slice(-2);
1976
+ var statusColor =
1977
+ r.statusCode === 200
1978
+ ? "var(--green)"
1979
+ : r.statusCode === 429
1980
+ ? "var(--yellow)"
1981
+ : "var(--red)";
1982
+ var color = getModelColor(r.model);
1983
+ var tokens =
1984
+ r.inputTokens || r.outputTokens
1985
+ ? formatTokenCount(r.inputTokens) +
1986
+ "/" +
1987
+ formatTokenCount(r.outputTokens)
1988
+ : "-";
1989
+ html +=
1990
+ '<tr style="border-top:1px solid var(--border)">' +
1991
+ '<td style="padding:3px 6px;color:var(--text-dim)">' +
1992
+ time +
1993
+ "</td>" +
1994
+ '<td style="padding:3px 6px"><span style="display:inline-block;width:6px;height:6px;border-radius:2px;background:' +
1995
+ color +
1996
+ ';margin-right:4px"></span>' +
1997
+ escapeHtml(r.model) +
1998
+ "</td>" +
1999
+ '<td style="padding:3px 6px">' +
2000
+ (MASK_MODE ? "***" : escapeHtml(r.account)) +
2001
+ "</td>" +
2002
+ '<td style="padding:3px 6px;color:' +
2003
+ statusColor +
2004
+ ';font-weight:700">' +
2005
+ r.statusCode +
2006
+ "</td>" +
2007
+ '<td style="padding:3px 6px">' +
2008
+ formatMs(r.ttfbMs) +
2009
+ "</td>" +
2010
+ '<td style="padding:3px 6px">' +
2011
+ formatMs(r.totalMs) +
2012
+ "</td>" +
2013
+ '<td style="padding:3px 6px">' +
2014
+ tokens +
2015
+ "</td>" +
2016
+ "</tr>";
2017
+ });
2018
+
2019
+ html += "</table>";
2020
+ if (filtered.length === 0)
2021
+ html =
2022
+ '<div style="color:var(--text-dim);text-align:center;padding:12px">No matching requests</div>';
2023
+ grid.innerHTML = html;
2024
+ }
2025
+
2026
+ // Wire up filter inputs to re-render
2027
+ (function () {
2028
+ ["logFilterModel", "logFilterAccount", "logFilterStatus"].forEach(
2029
+ function (id) {
2030
+ var el = document.getElementById(id);
2031
+ if (el)
2032
+ el.addEventListener("input", function () {
2033
+ if (window.__lastData) renderRequestLog(window.__lastData.requestLog);
2034
+ });
2035
+ },
2036
+ );
2037
+ })();
2038
+
2039
+ function maskEventMessage(msg) {
2040
+ if (!MASK_MODE) return escapeHtml(msg);
2041
+ var out = msg;
2042
+ if (window.__lastData && window.__lastData.accounts) {
2043
+ window.__lastData.accounts.forEach(function (a) {
2044
+ if (a.label && out.indexOf(a.label) !== -1) {
2045
+ out = out.split(a.label).join("***");
2046
+ }
2047
+ if (a.email && out.indexOf(a.email) !== -1) {
2048
+ out = out.split(a.email).join("***");
2049
+ }
2050
+ });
2051
+ }
2052
+ return escapeHtml(out);
2053
+ }
2054
+
2055
+ function renderRecentEvents(events) {
2056
+ var panel = document.getElementById("recentEventsPanel");
2057
+ var allEvents = events || [];
2058
+ if (allEvents.length === 0) {
2059
+ panel.style.display = "none";
2060
+ panel.innerHTML = "";
2061
+ return;
2062
+ }
2063
+
2064
+ var list = allEvents.filter(matchesEventFilter).slice(0, 14);
2065
+ var toolbar =
2066
+ '<div class="events-toolbar">' +
2067
+ renderEventFilterButton("all", "All") +
2068
+ renderEventFilterButton("errors", "Errors Only") +
2069
+ renderEventFilterButton("proxy", "Proxy Only") +
2070
+ renderEventFilterButton("rotator", "Rotator Only") +
2071
+ "</div>";
2072
+ var rows = list
2073
+ .map(function (event) {
2074
+ return (
2075
+ '<div class="event-item level-' +
2076
+ (event.level || "info") +
2077
+ '">' +
2078
+ '<div class="event-time">' +
2079
+ formatTime(event.timestamp) +
2080
+ "</div>" +
2081
+ '<div class="event-source ' +
2082
+ event.source +
2083
+ '">' +
2084
+ escapeHtml(event.source) +
2085
+ "</div>" +
2086
+ '<div class="event-message">' +
2087
+ maskEventMessage(event.message) +
2088
+ "</div>" +
2089
+ "</div>"
2090
+ );
2091
+ })
2092
+ .join("");
2093
+
2094
+ panel.style.display = "block";
2095
+ panel.innerHTML =
2096
+ '<div class="operator-title">Recent Events</div>' +
2097
+ toolbar +
2098
+ (rows
2099
+ ? '<div class="events-list">' + rows + "</div>"
2100
+ : '<div class="events-empty">No events match the current filter.</div>');
2101
+ }
2102
+
2103
+ function renderEventFilterButton(filter, label) {
2104
+ return (
2105
+ '<button class="event-filter' +
2106
+ (EVENT_FILTER === filter ? " active" : "") +
2107
+ '" onclick="setEventFilter(&quot;' +
2108
+ filter +
2109
+ '&quot;)">' +
2110
+ label +
2111
+ "</button>"
2112
+ );
2113
+ }
2114
+
2115
+ function matchesEventFilter(event) {
2116
+ if (EVENT_FILTER === "errors") return event.level === "error";
2117
+ if (EVENT_FILTER === "proxy") return event.source === "proxy";
2118
+ if (EVENT_FILTER === "rotator") return event.source === "rotator";
2119
+ return true;
2120
+ }
2121
+
2122
+ var MASK_MODE = new URLSearchParams(window.location.search).has("mask");
2123
+ var EVENT_FILTER =
2124
+ new URLSearchParams(window.location.search).get("events") || "all";
2125
+ var maskCounter = 0;
2126
+ var maskMap = {};
2127
+
2128
+ function maskText(text) {
2129
+ if (!MASK_MODE) return text;
2130
+ if (!maskMap[text]) {
2131
+ maskCounter++;
2132
+ maskMap[text] = "Account " + maskCounter;
2133
+ }
2134
+ return maskMap[text];
2135
+ }
2136
+
2137
+ function maskEmail(email) {
2138
+ if (!MASK_MODE) return email;
2139
+ var masked = maskText(email.split("@")[0]);
2140
+ return masked.toLowerCase().replace(/ /g, "-") + "@***.com";
2141
+ }
2142
+
2143
+ function escapeHtml(text) {
2144
+ return String(text)
2145
+ .replace(/&/g, "&amp;")
2146
+ .replace(/</g, "&lt;")
2147
+ .replace(/>/g, "&gt;")
2148
+ .replace(/"/g, "&quot;")
2149
+ .replace(/'/g, "&#39;");
2150
+ }
2151
+
2152
+ function jsString(text) {
2153
+ return escapeHtml(
2154
+ String(text)
2155
+ .replace(/\\/g, "\\\\")
2156
+ .replace(/'/g, "\\'")
2157
+ .replace(/\r/g, "\\r")
2158
+ .replace(/\n/g, "\\n"),
2159
+ );
2160
+ }
2161
+
2162
+ var ADMIN_TOKEN =
2163
+ new URLSearchParams(window.location.search).get("token") ||
2164
+ localStorage.getItem("rotatorAdminToken") ||
2165
+ "";
2166
+ if (ADMIN_TOKEN) localStorage.setItem("rotatorAdminToken", ADMIN_TOKEN);
2167
+
2168
+ function authHeaders() {
2169
+ return ADMIN_TOKEN ? { "X-Rotator-Admin-Token": ADMIN_TOKEN } : {};
2170
+ }
2171
+
2172
+ function authFetch(url, options) {
2173
+ options = options || {};
2174
+ options.headers = Object.assign({}, authHeaders(), options.headers || {});
2175
+ return fetch(url, options);
2176
+ }
2177
+
2178
+ function authEventUrl(path) {
2179
+ if (!ADMIN_TOKEN) return path;
2180
+ var url = new URL(path, window.location.origin);
2181
+ url.searchParams.set("token", ADMIN_TOKEN);
2182
+ return url.pathname + url.search;
2183
+ }
2184
+
2185
+ function setEventFilter(filter) {
2186
+ EVENT_FILTER = filter;
2187
+ refresh();
2188
+ }
2189
+
2190
+ async function loadConfigEditor() {
2191
+ var res = await authFetch("/api/config");
2192
+ var data = await res.json();
2193
+ document.getElementById("configEditor").value = JSON.stringify(data, null, 2);
2194
+ document.getElementById("configEditorStatus").textContent =
2195
+ "Loaded current config from disk.";
2196
+ }
2197
+
2198
+ async function saveConfigEditor() {
2199
+ var raw = document.getElementById("configEditor").value;
2200
+ try {
2201
+ var parsed = JSON.parse(raw);
2202
+ var res = await authFetch("/api/config", {
2203
+ method: "PUT",
2204
+ headers: { "Content-Type": "application/json" },
2205
+ body: JSON.stringify(parsed),
2206
+ });
2207
+ var data = await res.json();
2208
+ if (!res.ok)
2209
+ throw new Error(
2210
+ (data.errors || [data.error || "Invalid config"]).join("; "),
2211
+ );
2212
+ document.getElementById("configEditorStatus").textContent =
2213
+ "Saved config and refreshed runtime.";
2214
+ refresh();
2215
+ } catch (err) {
2216
+ document.getElementById("configEditorStatus").textContent =
2217
+ "Save failed: " + (err && err.message ? err.message : String(err));
2218
+ }
2219
+ }
2220
+
2221
+ async function exportConfig() {
2222
+ var res = await authFetch("/api/config/export");
2223
+ var data = await res.text();
2224
+ var blob = new Blob([data], { type: "application/json" });
2225
+ var url = URL.createObjectURL(blob);
2226
+ var a = document.createElement("a");
2227
+ a.href = url;
2228
+ a.download = "pi-antigravity-rotator-config.json";
2229
+ a.click();
2230
+ URL.revokeObjectURL(url);
2231
+ }
2232
+
2233
+ async function importConfigPrompt() {
2234
+ var raw = prompt("Paste a full config JSON document to import.");
2235
+ if (!raw) return;
2236
+ try {
2237
+ var parsed = JSON.parse(raw);
2238
+ var res = await authFetch("/api/config/import", {
2239
+ method: "POST",
2240
+ headers: { "Content-Type": "application/json" },
2241
+ body: JSON.stringify(parsed),
2242
+ });
2243
+ var data = await res.json();
2244
+ if (!res.ok)
2245
+ throw new Error(
2246
+ (data.errors || [data.error || "Import failed"]).join("; "),
2247
+ );
2248
+ document.getElementById("configEditor").value = JSON.stringify(
2249
+ parsed,
2250
+ null,
2251
+ 2,
2252
+ );
2253
+ document.getElementById("configEditorStatus").textContent =
2254
+ "Imported config successfully.";
2255
+ refresh();
2256
+ } catch (err) {
2257
+ document.getElementById("configEditorStatus").textContent =
2258
+ "Import failed: " + (err && err.message ? err.message : String(err));
2259
+ }
2260
+ }
2261
+
2262
+ async function openConfigEditorModal() {
2263
+ openModal("configEditorModal");
2264
+ await loadConfigEditor();
2265
+ }
2266
+
2267
+ function renderRoutingInspector(data) {
2268
+ var panel = document.getElementById("routingInspectorPanel");
2269
+ if (!panel) return;
2270
+ var diagnostics =
2271
+ data && data.routingDiagnostics ? data.routingDiagnostics : {};
2272
+ var modelKeys = Object.keys(diagnostics).sort();
2273
+ if (modelKeys.length === 0) {
2274
+ panel.innerHTML =
2275
+ '<div class="modal-empty">No routing diagnostics available yet.</div>';
2276
+ return;
2277
+ }
2278
+
2279
+ panel.innerHTML = modelKeys
2280
+ .map(function (modelKey) {
2281
+ var diag = diagnostics[modelKey];
2282
+ var rows = (diag.accounts || [])
2283
+ .map(function (entry) {
2284
+ var score =
2285
+ entry.score === null || entry.score === undefined
2286
+ ? "--"
2287
+ : Number(entry.score).toFixed(1);
2288
+ var quota =
2289
+ entry.quota === null || entry.quota === undefined
2290
+ ? "--"
2291
+ : entry.quota + "%";
2292
+ var priority =
2293
+ entry.timerPriority === null || entry.timerPriority === undefined
2294
+ ? "--"
2295
+ : String(entry.timerPriority);
2296
+ var tokenText =
2297
+ entry.tokenBucket && entry.tokenBucket.enabled
2298
+ ? Number(entry.tokenBucket.tokens || 0).toFixed(1) +
2299
+ " / " +
2300
+ entry.tokenBucket.capacity
2301
+ : "off";
2302
+ var decision = entry.rejectedReason
2303
+ ? '<span style="color:var(--yellow)">' +
2304
+ escapeHtml(entry.rejectedDetail || entry.rejectedReason) +
2305
+ "</span>"
2306
+ : '<span style="color:var(--green)">selected candidate</span>';
2307
+ return (
2308
+ "<tr>" +
2309
+ '<td style="padding:6px 8px;border-top:1px solid var(--border)">' +
2310
+ escapeHtml(maskText(entry.label || entry.email)) +
2311
+ "</td>" +
2312
+ '<td style="padding:6px 8px;border-top:1px solid var(--border)">' +
2313
+ escapeHtml(entry.status) +
2314
+ "</td>" +
2315
+ '<td style="padding:6px 8px;border-top:1px solid var(--border);font-family:JetBrains Mono,monospace">' +
2316
+ escapeHtml(priority) +
2317
+ "</td>" +
2318
+ '<td style="padding:6px 8px;border-top:1px solid var(--border);font-family:JetBrains Mono,monospace">' +
2319
+ escapeHtml(quota) +
2320
+ "</td>" +
2321
+ '<td style="padding:6px 8px;border-top:1px solid var(--border);font-family:JetBrains Mono,monospace">' +
2322
+ escapeHtml(
2323
+ String(Math.round((entry.healthScore || 0) * 100)) + "%",
2324
+ ) +
2325
+ "</td>" +
2326
+ '<td style="padding:6px 8px;border-top:1px solid var(--border);font-family:JetBrains Mono,monospace">' +
2327
+ escapeHtml(tokenText) +
2328
+ "</td>" +
2329
+ '<td style="padding:6px 8px;border-top:1px solid var(--border);font-family:JetBrains Mono,monospace">' +
2330
+ escapeHtml(score) +
2331
+ "</td>" +
2332
+ '<td style="padding:6px 8px;border-top:1px solid var(--border)">' +
2333
+ decision +
2334
+ "</td>" +
2335
+ "</tr>"
2336
+ );
2337
+ })
2338
+ .join("");
2339
+
2340
+ return (
2341
+ '<div style="margin-bottom:16px;padding:14px;border:1px solid var(--border);border-radius:8px;background:rgba(255,255,255,0.02)">' +
2342
+ '<div style="display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:8px">' +
2343
+ "<strong>" +
2344
+ escapeHtml(modelKey) +
2345
+ "</strong>" +
2346
+ '<span style="font-size:12px;color:var(--text-dim)">policy: ' +
2347
+ escapeHtml(diag.policy || "--") +
2348
+ " · available: " +
2349
+ escapeHtml(String(diag.availableCandidates || 0)) +
2350
+ " · rejected: " +
2351
+ escapeHtml(String(diag.rejectedCandidates || 0)) +
2352
+ "</span>" +
2353
+ "</div>" +
2354
+ '<div style="font-size:12px;color:var(--text-dim);margin-bottom:10px">' +
2355
+ escapeHtml(diag.reason || "No diagnostic summary available.") +
2356
+ "</div>" +
2357
+ '<div style="overflow:auto">' +
2358
+ '<table style="width:100%;border-collapse:collapse;font-size:12px">' +
2359
+ '<thead><tr style="text-align:left;color:var(--text-dim)">' +
2360
+ '<th style="padding:4px 8px">Account</th>' +
2361
+ '<th style="padding:4px 8px">Status</th>' +
2362
+ '<th style="padding:4px 8px">Timer</th>' +
2363
+ '<th style="padding:4px 8px">Quota</th>' +
2364
+ '<th style="padding:4px 8px">Health</th>' +
2365
+ '<th style="padding:4px 8px">Bucket</th>' +
2366
+ '<th style="padding:4px 8px">Score</th>' +
2367
+ '<th style="padding:4px 8px">Decision</th>' +
2368
+ "</tr></thead>" +
2369
+ "<tbody>" +
2370
+ rows +
2371
+ "</tbody>" +
2372
+ "</table>" +
2373
+ "</div>" +
2374
+ "</div>"
2375
+ );
2376
+ })
2377
+ .join("");
2378
+ }
2379
+
2380
+ function openRoutingInspectorModal() {
2381
+ openModal("routingInspectorModal");
2382
+ renderRoutingInspector(window.__lastData || {});
2383
+ }
2384
+
2385
+ async function enableAccount(email) {
2386
+ await authFetch("/api/enable/" + encodeURIComponent(email), {
2387
+ method: "POST",
2388
+ });
2389
+ refresh();
2390
+ }
2391
+
2392
+ async function disableAccount(email) {
2393
+ if (
2394
+ !confirm(
2395
+ "Disable this account? It will stop serving traffic until restored.",
2396
+ )
2397
+ )
2398
+ return;
2399
+ await authFetch("/api/disable/" + encodeURIComponent(email), {
2400
+ method: "POST",
2401
+ });
2402
+ refresh();
2403
+ }
2404
+
2405
+ async function quarantineAccount(email) {
2406
+ if (
2407
+ !confirm(
2408
+ "Quarantine this account? It will be flagged and excluded from routing.",
2409
+ )
2410
+ )
2411
+ return;
2412
+ await authFetch("/api/quarantine/" + encodeURIComponent(email), {
2413
+ method: "POST",
2414
+ });
2415
+ refresh();
2416
+ }
2417
+
2418
+ async function restoreAccount(email) {
2419
+ await authFetch("/api/restore/" + encodeURIComponent(email), {
2420
+ method: "POST",
2421
+ });
2422
+ refresh();
2423
+ }
2424
+
2425
+ async function removeAccount(email) {
2426
+ try {
2427
+ var r = await authFetch(
2428
+ "/api/remove-account/" + encodeURIComponent(email),
2429
+ { method: "POST" },
2430
+ );
2431
+ var d = await r.json();
2432
+ if (d.ok) refresh();
2433
+ else alert("Failed to remove: " + JSON.stringify(d));
2434
+ } catch (e) {
2435
+ alert("Failed to remove: " + e);
2436
+ }
2437
+ }
2438
+
2439
+ function confirmRemoveAccount(email) {
2440
+ if (confirm("Remove account " + email + "? This cannot be undone.")) {
2441
+ removeAccount(email);
2442
+ }
2443
+ }
2444
+
2445
+ function openCliLogin() {
2446
+ var path =
2447
+ window.__lastData && window.__lastData.hostedOAuthConfigured
2448
+ ? "/login"
2449
+ : "/login-cli";
2450
+ window.open(
2451
+ path + (ADMIN_TOKEN ? "?token=" + encodeURIComponent(ADMIN_TOKEN) : ""),
2452
+ "_blank",
2453
+ );
2454
+ }
2455
+
2456
+ function toggleTierDropdown(badge, email) {
2457
+ var dropdown = badge.nextElementSibling;
2458
+ var isOpen = dropdown.style.display !== "none";
2459
+ // Close all other dropdowns first
2460
+ document.querySelectorAll(".tier-dropdown").forEach(function (d) {
2461
+ d.style.display = "none";
2462
+ });
2463
+ if (!isOpen) {
2464
+ dropdown.style.display = "block";
2465
+ // Close on click outside
2466
+ setTimeout(function () {
2467
+ document.addEventListener("click", function closeDropdown(e) {
2468
+ if (!dropdown.contains(e.target) && e.target !== badge) {
2469
+ dropdown.style.display = "none";
2470
+ document.removeEventListener("click", closeDropdown);
2471
+ }
2472
+ });
2473
+ }, 0);
2474
+ }
2475
+ }
2476
+
2477
+ async function setAccountTier(email, tier) {
2478
+ try {
2479
+ var r = await authFetch(
2480
+ "/api/set-tier/" +
2481
+ encodeURIComponent(email) +
2482
+ "/" +
2483
+ encodeURIComponent(tier),
2484
+ { method: "POST" },
2485
+ );
2486
+ var d = await r.json();
2487
+ if (d.ok) refresh();
2488
+ else alert("Failed to set tier: " + JSON.stringify(d));
2489
+ } catch (e) {
2490
+ alert("Failed to set tier: " + e);
2491
+ }
2492
+ }
2493
+
2494
+ async function setFreshWindowStarts(enabled) {
2495
+ await authFetch(
2496
+ "/api/settings/fresh-window-starts/" + (enabled ? "on" : "off"),
2497
+ { method: "POST" },
2498
+ );
2499
+ refresh();
2500
+ }
2501
+
2502
+ async function setAutoWarmup(enabled) {
2503
+ await authFetch(
2504
+ "/api/settings/auto-warmup/" + (enabled ? "on" : "off"),
2505
+ { method: "POST" },
2506
+ );
2507
+ refresh();
2508
+ }
2509
+
2510
+ async function kickstartTimer(email, modelKey) {
2511
+ var res = await authFetch(
2512
+ "/api/kickstart/" +
2513
+ encodeURIComponent(email) +
2514
+ "/" +
2515
+ encodeURIComponent(modelKey),
2516
+ { method: "POST" },
2517
+ );
2518
+ var data = await res.json();
2519
+ if (!data.ok) {
2520
+ alert(
2521
+ "Kickstart failed for " +
2522
+ modelKey +
2523
+ ": " +
2524
+ (data.error || "status " + data.status),
2525
+ );
2526
+ }
2527
+ refresh();
2528
+ }
2529
+
2530
+ async function kickstartAllTimers(email) {
2531
+ if (
2532
+ !confirm(
2533
+ "Send minimal requests to start all idle timers on " +
2534
+ email +
2535
+ "? One request per upstream model pool will be sent.",
2536
+ )
2537
+ )
2538
+ return;
2539
+ var res = await authFetch(
2540
+ "/api/kickstart/" + encodeURIComponent(email),
2541
+ { method: "POST" },
2542
+ );
2543
+ var data = await res.json();
2544
+ if (data.error) {
2545
+ alert("Kickstart failed: " + data.error);
2546
+ } else if (data.results && data.results.length === 0) {
2547
+ alert("No idle timers found for this account.");
2548
+ }
2549
+ refresh();
2550
+ }
2551
+
2552
+ function toggleFlagged() {
2553
+ window.__hideFlagged = !window.__hideFlagged;
2554
+ refresh();
2555
+ }
2556
+
2557
+ async function setAccountFreshWindowOverride(email, enabled) {
2558
+ await authFetch(
2559
+ "/api/account-fresh-window-starts/" +
2560
+ encodeURIComponent(email) +
2561
+ "/" +
2562
+ (enabled ? "on" : "off"),
2563
+ { method: "POST" },
2564
+ );
2565
+ refresh();
2566
+ }
2567
+
2568
+ async function clearInFlight(email, modelKey) {
2569
+ if (
2570
+ !confirm(
2571
+ "Clear in-flight counter for this account/model? Use only when you are sure the request is stuck.",
2572
+ )
2573
+ )
2574
+ return;
2575
+ await authFetch(
2576
+ "/api/clear-inflight/" +
2577
+ encodeURIComponent(email) +
2578
+ "/" +
2579
+ encodeURIComponent(modelKey),
2580
+ { method: "POST" },
2581
+ );
2582
+ refresh();
2583
+ }
2584
+
2585
+ async function clearCircuitBreaker(modelKey) {
2586
+ var target = modelKey ? modelKey : "ALL";
2587
+ if (
2588
+ !confirm(
2589
+ "Manually reset the circuit breaker for " +
2590
+ target +
2591
+ "? If the provider issue is still ongoing, this could lead to more rate-limits.",
2592
+ )
2593
+ )
2594
+ return;
2595
+ var path =
2596
+ "/api/clear-breaker/" + (modelKey ? encodeURIComponent(modelKey) : "all");
2597
+ await authFetch(path, { method: "POST" });
2598
+ refresh();
2599
+ }
2600
+
2601
+ function openModal(id) {
2602
+ var modal = document.getElementById(id);
2603
+ if (modal) modal.classList.add("open");
2604
+ }
2605
+
2606
+ function closeModal(event, id) {
2607
+ if (event) event.stopPropagation();
2608
+ var modal = document.getElementById(id);
2609
+ if (modal) modal.classList.remove("open");
2610
+ }
2611
+
2612
+ async function refresh() {
2613
+ try {
2614
+ var res = await authFetch("/api/status");
2615
+ var data = await res.json();
2616
+ renderAccounts(data);
2617
+ var btn = document.getElementById("maskBtn");
2618
+ if (btn) btn.textContent = MASK_MODE ? "PII: Hidden" : "PII: Visible";
2619
+ } catch (err) {
2620
+ console.error("Status fetch failed:", err);
2621
+ }
2622
+ }
2623
+
2624
+ // Live updates via SSE
2625
+ var evtSource = null;
2626
+ function connectSSE() {
2627
+ if (evtSource) evtSource.close();
2628
+ evtSource = new EventSource(authEventUrl("/api/events"));
2629
+ evtSource.onmessage = function (e) {
2630
+ try {
2631
+ var data = JSON.parse(e.data);
2632
+ renderAccounts(data);
2633
+ } catch (err) {
2634
+ console.error("SSE parse error:", err);
2635
+ }
2636
+ };
2637
+ evtSource.onerror = function () {
2638
+ // reconnect after 5s on error
2639
+ evtSource.close();
2640
+ evtSource = null;
2641
+ setTimeout(connectSSE, 5000);
2642
+ };
2643
+ }
2644
+
2645
+ function toggleMask() {
2646
+ var url = new URL(window.location);
2647
+ if (MASK_MODE) {
2648
+ url.searchParams.delete("mask");
2649
+ } else {
2650
+ url.searchParams.set("mask", "1");
2651
+ }
2652
+ window.location.href = url.toString();
2653
+ }
2654
+
2655
+ document.addEventListener("keydown", function (event) {
2656
+ if (event.key === "Escape") {
2657
+ closeModal(null, "attentionModal");
2658
+ closeModal(null, "configEditorModal");
2659
+ closeModal(null, "routingInspectorModal");
2660
+ closeModal(null, "advisorModal");
2661
+ closeModal(null, "donationModal");
2662
+ }
2663
+ });
2664
+
2665
+ function hideDonationModalPermanently() {
2666
+ localStorage.setItem("hideDonationPopup", "true");
2667
+ closeModal(null, "donationModal");
2668
+ }
2669
+
2670
+ refresh();
2671
+ connectSSE();
2672
+ setInterval(refresh, 15000); // fallback poll every 15s in case SSE drops
2673
+
2674
+ if (!localStorage.getItem("hideDonationPopup")) {
2675
+ setTimeout(function () {
2676
+ openModal("donationModal");
2677
+ }, 1000);
2678
+ }
2679
+
2680
+ // ── Update Banner ──
2681
+ function renderUpdateBanner(updateInfo) {
2682
+ var banner = document.getElementById("updateBanner");
2683
+ var message = document.getElementById("updateMessage");
2684
+ var actions = document.getElementById("updateActions");
2685
+ var badgeLabel = document.getElementById("updateBadgeLabel");
2686
+
2687
+ if (!updateInfo || !banner || !message || !actions) return;
2688
+
2689
+ // Check if update was already applied (waiting for restart)
2690
+ var pendingRestart = localStorage.getItem("updatePendingRestart");
2691
+ if (pendingRestart) {
2692
+ banner.className = "update-banner visible success";
2693
+ badgeLabel.textContent = "✓";
2694
+ message.innerHTML =
2695
+ "<strong>Updated to v" +
2696
+ escapeHtml(pendingRestart) +
2697
+ "</strong> — Restart the process to apply the new version.";
2698
+ actions.innerHTML =
2699
+ '<button class="btn-update-dismiss" onclick="clearPendingRestart()">Dismiss</button>';
2700
+ return;
2701
+ }
2702
+
2703
+ if (!updateInfo.updateAvailable || !updateInfo.latestVersion) {
2704
+ banner.className = "update-banner";
2705
+ return;
2706
+ }
2707
+
2708
+ // Check if user dismissed this specific version
2709
+ var dismissed = localStorage.getItem("updateDismissed");
2710
+ if (dismissed === updateInfo.latestVersion) {
2711
+ banner.className = "update-banner";
2712
+ return;
2713
+ }
2714
+
2715
+ banner.className = "update-banner visible";
2716
+ badgeLabel.textContent = "NEW";
2717
+ message.innerHTML =
2718
+ "🚀 Version <strong>v" +
2719
+ escapeHtml(updateInfo.latestVersion) +
2720
+ "</strong> is available " +
2721
+ '<span style="color:var(--text-dim)">(current: v' +
2722
+ escapeHtml(updateInfo.currentVersion) +
2723
+ ")</span>";
2724
+ actions.innerHTML =
2725
+ '<a class="btn-update-link" href="https://github.com/tuxevil/pi-antigravity-rotator/releases" target="_blank">Changelog</a>' +
2726
+ '<button class="btn-update" id="btnDoUpdate" onclick="doSelfUpdate()">Update Now</button>' +
2727
+ '<button class="btn-update-dismiss" onclick="dismissUpdate(\'' +
2728
+ escapeHtml(updateInfo.latestVersion) +
2729
+ "')\">Dismiss</button>";
2730
+ }
2731
+
2732
+ async function doSelfUpdate() {
2733
+ var btn = document.getElementById("btnDoUpdate");
2734
+ if (btn) {
2735
+ btn.disabled = true;
2736
+ btn.textContent = "Updating...";
2737
+ }
2738
+ try {
2739
+ var res = await authFetch("/api/self-update", { method: "POST" });
2740
+ var result = await res.json();
2741
+ var banner = document.getElementById("updateBanner");
2742
+ var message = document.getElementById("updateMessage");
2743
+ var actions = document.getElementById("updateActions");
2744
+ var badgeLabel = document.getElementById("updateBadgeLabel");
2745
+ if (result.ok) {
2746
+ localStorage.setItem("updatePendingRestart", result.to);
2747
+ banner.className = "update-banner visible success";
2748
+ badgeLabel.textContent = "✓";
2749
+ message.innerHTML =
2750
+ "<strong>Updated to v" +
2751
+ escapeHtml(result.to) +
2752
+ "</strong> — Restart the process to apply the new version.";
2753
+ actions.innerHTML =
2754
+ '<button class="btn-update-dismiss" onclick="clearPendingRestart()">Dismiss</button>';
2755
+ } else {
2756
+ message.innerHTML =
2757
+ '<strong style="color:var(--red)">Update failed</strong> — ' +
2758
+ escapeHtml(result.message || "Unknown error");
2759
+ if (btn) {
2760
+ btn.disabled = false;
2761
+ btn.textContent = "Retry";
2762
+ }
2763
+ }
2764
+ } catch (err) {
2765
+ var message2 = document.getElementById("updateMessage");
2766
+ if (message2)
2767
+ message2.innerHTML =
2768
+ '<strong style="color:var(--red)">Update failed</strong> — ' +
2769
+ escapeHtml(String(err));
2770
+ if (btn) {
2771
+ btn.disabled = false;
2772
+ btn.textContent = "Retry";
2773
+ }
2774
+ }
2775
+ }
2776
+
2777
+ function dismissUpdate(version) {
2778
+ localStorage.setItem("updateDismissed", version);
2779
+ var banner = document.getElementById("updateBanner");
2780
+ if (banner) banner.className = "update-banner";
2781
+ }
2782
+
2783
+ function clearPendingRestart() {
2784
+ localStorage.removeItem("updatePendingRestart");
2785
+ var banner = document.getElementById("updateBanner");
2786
+ if (banner) banner.className = "update-banner";
2787
+ }
2788
+
2789
+ // ── Admin Notifications ──
2790
+ var NOTIF_ICONS = { info: "ℹ️", warning: "⚠️", critical: "🚨" };
2791
+
2792
+ function renderNotifications(notifications) {
2793
+ var container = document.getElementById("notifContainer");
2794
+ if (!container) return;
2795
+ if (!notifications || notifications.length === 0) {
2796
+ container.innerHTML = "";
2797
+ updateNotifBellBadge(0);
2798
+ return;
2799
+ }
2800
+
2801
+ var visibleCount = 0;
2802
+ var html = "";
2803
+ for (var i = 0; i < notifications.length; i++) {
2804
+ var n = notifications[i];
2805
+ // Check if user dismissed this notification
2806
+ if (localStorage.getItem("notif-dismissed-" + n.id)) continue;
2807
+ visibleCount++;
2808
+ var icon = NOTIF_ICONS[n.type] || NOTIF_ICONS.info;
2809
+ var typeClass = "notif-" + (n.type || "info");
2810
+ html +=
2811
+ '<div class="notif-banner ' +
2812
+ typeClass +
2813
+ '" id="notif-' +
2814
+ escapeHtml(n.id) +
2815
+ '">';
2816
+ html += '<span class="notif-icon">' + icon + "</span>";
2817
+ html += '<div class="notif-content">';
2818
+ html += '<div class="notif-title">' + escapeHtml(n.title) + "</div>";
2819
+ html += '<div class="notif-msg">' + escapeHtml(n.message) + "</div>";
2820
+ if (n.actionUrl) {
2821
+ html += '<div class="notif-actions">';
2822
+ html +=
2823
+ '<a class="notif-action-btn" href="' +
2824
+ escapeHtml(n.actionUrl) +
2825
+ '" target="_blank">' +
2826
+ escapeHtml(n.actionLabel || "Learn More") +
2827
+ "</a>";
2828
+ html += "</div>";
2829
+ }
2830
+ html += "</div>";
2831
+ html +=
2832
+ '<button class="notif-dismiss" onclick="dismissNotification(\'' +
2833
+ escapeHtml(n.id) +
2834
+ '\')" title="Dismiss">&times;</button>';
2835
+ html += "</div>";
2836
+ }
2837
+ container.innerHTML = html;
2838
+ updateNotifBellBadge(visibleCount);
2839
+ }
2840
+
2841
+ function dismissNotification(id) {
2842
+ localStorage.setItem("notif-dismissed-" + id, "1");
2843
+ var el = document.getElementById("notif-" + id);
2844
+ if (el) {
2845
+ el.style.opacity = "0";
2846
+ el.style.transform = "translateY(-10px)";
2847
+ el.style.transition = "opacity 0.3s, transform 0.3s";
2848
+ setTimeout(function () {
2849
+ el.remove();
2850
+ }, 300);
2851
+ }
2852
+ // Recount visible
2853
+ var container = document.getElementById("notifContainer");
2854
+ if (container) {
2855
+ var remaining = container.querySelectorAll(".notif-banner").length - 1;
2856
+ updateNotifBellBadge(Math.max(0, remaining));
2857
+ }
2858
+ }
2859
+
2860
+ function updateNotifBellBadge(count) {
2861
+ // Update the attention bell badge if it exists
2862
+ var bellBtn = document.querySelector(".header-icon-btn.attention");
2863
+ if (!bellBtn) return;
2864
+ var badge = bellBtn.querySelector(".header-icon-badge");
2865
+ if (count > 0) {
2866
+ bellBtn.classList.add("has-items");
2867
+ if (badge) {
2868
+ badge.textContent = String(count);
2869
+ badge.style.display = "";
2870
+ }
2871
+ }
2872
+ }