pi-antigravity-rotator 2.3.0 → 2.3.1

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