pi-antigravity-rotator 2.3.0 → 2.3.2

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