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.
- package/CHANGELOG.md +5 -0
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/dashboard.ts +35 -0
- package/src/proxy.ts +29 -0
- package/src/rotator.ts +261 -0
- package/src/static/dashboard.js +1966 -928
- package/src/types.ts +2 -0
- package/tools/telemetry-receiver/receiver.js +5 -0
package/src/static/dashboard.js
CHANGED
|
@@ -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 +
|
|
4
|
+
if (s < 60) return s + "s";
|
|
6
5
|
var m = Math.floor(s / 60);
|
|
7
|
-
if (m < 60) return m +
|
|
6
|
+
if (m < 60) return m + "m " + (s % 60) + "s";
|
|
8
7
|
var h = Math.floor(m / 60);
|
|
9
|
-
if (h < 24) return h +
|
|
8
|
+
if (h < 24) return h + "h " + (m % 60) + "m";
|
|
10
9
|
var d = Math.floor(h / 24);
|
|
11
|
-
return d +
|
|
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
|
|
21
|
-
if (pct >= 30) return
|
|
22
|
-
return
|
|
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 ===
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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(
|
|
72
|
-
document.getElementById(
|
|
73
|
-
document.getElementById(
|
|
74
|
-
document.getElementById(
|
|
75
|
-
|
|
76
|
-
document.getElementById(
|
|
77
|
-
|
|
78
|
-
|
|
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 ||
|
|
127
|
+
var state = health.state || "stopped";
|
|
82
128
|
var stateColor = {
|
|
83
|
-
healthy:
|
|
84
|
-
paused:
|
|
85
|
-
cooldown_wait:
|
|
86
|
-
busy:
|
|
87
|
-
stopped:
|
|
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 =
|
|
90
|
-
var nextRetry =
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
?
|
|
99
|
-
:
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
(
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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 +=
|
|
142
|
-
'<
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 +=
|
|
150
|
-
'<
|
|
151
|
-
'<span style="color:var(--
|
|
152
|
-
|
|
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 +=
|
|
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(
|
|
285
|
+
var container = document.getElementById("accounts");
|
|
170
286
|
var hideFlagged = window.__hideFlagged || false;
|
|
171
|
-
var sorted = data.accounts
|
|
172
|
-
.
|
|
173
|
-
.
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (
|
|
201
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
(a.
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
(a.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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 =
|
|
271
|
-
var LIST_SORT =
|
|
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(
|
|
277
|
-
|
|
278
|
-
document.getElementById(
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
[
|
|
291
|
-
var btn = document.getElementById(
|
|
292
|
-
if (btn)
|
|
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(
|
|
301
|
-
var query = (
|
|
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 (
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
[
|
|
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])
|
|
324
|
-
|
|
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 (
|
|
564
|
+
rows.forEach(function (a) {
|
|
565
|
+
if (
|
|
566
|
+
!tokensByAccount[a.email] &&
|
|
567
|
+
(a.totalInputTokens || a.totalOutputTokens)
|
|
568
|
+
) {
|
|
333
569
|
tokensByAccount[a.email] = {
|
|
334
|
-
input:
|
|
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 ===
|
|
579
|
+
if (LIST_SORT === "requests") {
|
|
344
580
|
av = a.totalRequests || 0;
|
|
345
581
|
bv = b.totalRequests || 0;
|
|
346
|
-
} else if (LIST_SORT ===
|
|
347
|
-
av =
|
|
348
|
-
|
|
349
|
-
|
|
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 ===
|
|
355
|
-
var statusOrder = {
|
|
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;
|
|
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 =
|
|
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
|
|
629
|
+
return (
|
|
630
|
+
'<span class="sort-arrow">' +
|
|
631
|
+
(LIST_SORT_DIR === -1 ? "↓" : "↑") +
|
|
632
|
+
"</span>"
|
|
633
|
+
);
|
|
374
634
|
};
|
|
375
635
|
|
|
376
|
-
var html =
|
|
377
|
-
'<
|
|
378
|
-
|
|
379
|
-
'<th onclick="setListSort('
|
|
380
|
-
|
|
381
|
-
'
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
636
|
+
var html =
|
|
637
|
+
'<table class="list-table"><thead><tr>' +
|
|
638
|
+
"<th>Account</th>" +
|
|
639
|
+
'<th onclick="setListSort('status')" class="' +
|
|
640
|
+
(LIST_SORT === "status" ? "sort-active" : "") +
|
|
641
|
+
'">Status' +
|
|
642
|
+
arrowFor("status") +
|
|
643
|
+
"</th>" +
|
|
644
|
+
'<th onclick="setListSort('requests')" class="' +
|
|
645
|
+
(LIST_SORT === "requests" ? "sort-active" : "") +
|
|
646
|
+
'">Total Reqs' +
|
|
647
|
+
arrowFor("requests") +
|
|
648
|
+
"</th>" +
|
|
649
|
+
"<th>This Rotation</th>" +
|
|
650
|
+
'<th onclick="setListSort('quota')" class="' +
|
|
651
|
+
(LIST_SORT === "quota" ? "sort-active" : "") +
|
|
652
|
+
'">Avg Quota' +
|
|
653
|
+
arrowFor("quota") +
|
|
654
|
+
"</th>" +
|
|
655
|
+
'<th onclick="setListSort('tokens')" 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:
|
|
394
|
-
|
|
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] ||
|
|
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 =
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
'<
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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('' +
|
|
724
|
+
jsString(a.email) +
|
|
725
|
+
'')">' +
|
|
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 +=
|
|
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(
|
|
435
|
-
setTimeout(function() {
|
|
436
|
-
var target = document.querySelector(
|
|
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:
|
|
439
|
-
target.classList.add(
|
|
440
|
-
setTimeout(function() {
|
|
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
|
|
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(
|
|
451
|
-
var button = document.getElementById(
|
|
452
|
-
var badge = document.getElementById(
|
|
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) {
|
|
457
|
-
|
|
458
|
-
|
|
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) {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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) {
|
|
467
|
-
|
|
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) {
|
|
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(
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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(
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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(
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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(
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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(
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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(
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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(
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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 =
|
|
541
|
-
|
|
542
|
-
|
|
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 =
|
|
547
|
-
|
|
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(
|
|
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 ===
|
|
557
|
-
icon =
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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 =
|
|
567
|
-
|
|
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
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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[
|
|
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) +
|
|
605
|
-
if (n >= 1e3) return (n / 1e3).toFixed(1) +
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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 =
|
|
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
|
|
1043
|
+
byModel[m].inputUsd += (d.inputTokens / 1e6) * p.input;
|
|
635
1044
|
byModel[m].outputUsd += (d.outputTokens / 1e6) * p.output;
|
|
636
|
-
byModel[m].totalUsd
|
|
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 =
|
|
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 ===
|
|
650
|
-
var dataStr =
|
|
651
|
-
|
|
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 ===
|
|
1066
|
+
} else if (format === "csv") {
|
|
656
1067
|
var csv = "Tier,Period,Model,InputTokens,OutputTokens,Requests\n";
|
|
657
|
-
[
|
|
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 +=
|
|
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(
|
|
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(
|
|
1104
|
+
if (view.endsWith("h") && view !== "1d") {
|
|
682
1105
|
var d;
|
|
683
|
-
if (period.length === 16) d = new Date(period +
|
|
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 ===
|
|
687
|
-
return
|
|
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 ===
|
|
691
|
-
if (view ===
|
|
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(
|
|
698
|
-
var chart = document.getElementById(
|
|
699
|
-
var legend = document.getElementById(
|
|
700
|
-
var totals = document.getElementById(
|
|
701
|
-
var view = window.__tokenView ||
|
|
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
|
-
[
|
|
705
|
-
var btn = document.getElementById(
|
|
706
|
-
if (btn)
|
|
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 =
|
|
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])
|
|
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])
|
|
726
|
-
|
|
727
|
-
|
|
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)
|
|
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 +
|
|
738
|
-
else if (periodStr.length === 13) d = new Date(periodStr +
|
|
739
|
-
else if (periodStr.length === 16) d = new Date(periodStr +
|
|
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,
|
|
745
|
-
var da = String(d.getDate()).padStart(2,
|
|
746
|
-
var h = String(d.getHours()).padStart(2,
|
|
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 ===
|
|
750
|
-
if (type ===
|
|
751
|
-
if (type ===
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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 =
|
|
768
|
-
|
|
769
|
-
if (view ===
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
else if (view ===
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
else if (view ===
|
|
776
|
-
|
|
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) {
|
|
1284
|
+
data.forEach(function (b) {
|
|
1285
|
+
dataMap[b.period] = b;
|
|
1286
|
+
});
|
|
782
1287
|
} else {
|
|
783
|
-
data.forEach(function(b) {
|
|
784
|
-
var k = 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() -
|
|
792
|
-
var k = keyFn
|
|
793
|
-
|
|
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 || [])
|
|
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 ===
|
|
803
|
-
buckets = padBuckets(
|
|
804
|
-
} else if (view ===
|
|
805
|
-
buckets = padBuckets(
|
|
806
|
-
} else if (view ===
|
|
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) {
|
|
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 ===
|
|
1332
|
+
} else if (view === "8h") {
|
|
811
1333
|
var src8h = (tokenUsage.hours || []).concat(tokenUsage.minutes || []);
|
|
812
|
-
var kfn8h = function(p) {
|
|
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 ===
|
|
1338
|
+
} else if (view === "12h") {
|
|
815
1339
|
var src12h = (tokenUsage.hours || []).concat(tokenUsage.minutes || []);
|
|
816
|
-
var kfn12h = function(p) {
|
|
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 ===
|
|
819
|
-
var kfn1d = function(p) {
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
buckets = padBuckets(
|
|
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(
|
|
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 =
|
|
829
|
-
|
|
830
|
-
|
|
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) {
|
|
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(
|
|
1425
|
+
var segHeight = Math.max(
|
|
1426
|
+
0,
|
|
1427
|
+
(modelTokens / maxTokens) * (chartHeight - 20),
|
|
1428
|
+
);
|
|
869
1429
|
yOffset -= segHeight;
|
|
870
|
-
bars +=
|
|
871
|
-
'
|
|
872
|
-
|
|
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 +=
|
|
878
|
-
'
|
|
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 +=
|
|
887
|
-
|
|
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 =
|
|
891
|
-
|
|
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 =
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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) +
|
|
916
|
-
if (ms >= 1000) return (ms / 1000).toFixed(1) +
|
|
917
|
-
return 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(
|
|
922
|
-
var grid = document.getElementById(
|
|
1540
|
+
var panel = document.getElementById("heatmapPanel");
|
|
1541
|
+
var grid = document.getElementById("heatmapGrid");
|
|
923
1542
|
if (!tokenUsage) {
|
|
924
|
-
panel.style.display =
|
|
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 =
|
|
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:
|
|
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 +
|
|
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 +
|
|
951
|
-
else if (periodStr.length === 13) d = new Date(periodStr +
|
|
952
|
-
else if (periodStr.length === 16) d = new Date(periodStr +
|
|
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:
|
|
958
|
-
|
|
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 +
|
|
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
|
|
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
|
|
986
|
-
if (t < 0.4) return
|
|
987
|
-
if (t < 0.6) return
|
|
988
|
-
if (t < 0.8) return
|
|
989
|
-
return
|
|
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 =
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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 +=
|
|
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 +
|
|
1002
|
-
html +=
|
|
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 +=
|
|
1652
|
+
html += "</tr>";
|
|
1005
1653
|
}
|
|
1006
1654
|
|
|
1007
|
-
html +=
|
|
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(
|
|
1014
|
-
var grid = document.getElementById(
|
|
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: {
|
|
1020
|
-
accounts.forEach(function(a) {
|
|
1021
|
-
if (a.status ===
|
|
1022
|
-
(a.
|
|
1023
|
-
|
|
1024
|
-
modelQuota[q.modelKey]
|
|
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 {
|
|
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(
|
|
1057
|
-
if (
|
|
1058
|
-
|
|
1059
|
-
|
|
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 =
|
|
1749
|
+
panel.style.display = "none";
|
|
1067
1750
|
return;
|
|
1068
1751
|
}
|
|
1069
|
-
panel.style.display =
|
|
1752
|
+
panel.style.display = "";
|
|
1070
1753
|
|
|
1071
|
-
var html =
|
|
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
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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 =
|
|
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) +
|
|
1773
|
+
var rateLabel = rate > 0 ? rate.toFixed(1) + " req/h" : "idle";
|
|
1087
1774
|
var displayName = m;
|
|
1088
|
-
if (m ===
|
|
1089
|
-
if (m ===
|
|
1090
|
-
if (m ===
|
|
1775
|
+
if (m === "claude-opus-4-6-thinking") displayName = "claude";
|
|
1776
|
+
if (m === "gemini-3.1-pro") displayName = "gemini-3.1-pro";
|
|
1777
|
+
if (m === "gemini-3.5-flash") displayName = "gemini-3.5-flash";
|
|
1091
1778
|
|
|
1092
1779
|
var minResetRemaining = null;
|
|
1093
|
-
q.entries.forEach(function(entry) {
|
|
1094
|
-
if (entry.resetTime && entry.timerType !==
|
|
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 =
|
|
1098
|
-
|
|
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 =
|
|
1798
|
+
var nextResetLabel =
|
|
1799
|
+
minResetRemaining !== null ? formatDuration(minResetRemaining) : "--";
|
|
1108
1800
|
|
|
1109
|
-
// Estimate:
|
|
1110
|
-
|
|
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 =
|
|
1805
|
+
var estimateColor = "var(--text)";
|
|
1115
1806
|
if (rate <= 0) {
|
|
1116
|
-
estimateLabel =
|
|
1117
|
-
estimateColor =
|
|
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) +
|
|
1122
|
-
estimateColor =
|
|
1812
|
+
estimateLabel = (hoursLeft / 24).toFixed(1) + "d";
|
|
1813
|
+
estimateColor = "var(--green)";
|
|
1123
1814
|
} else if (hoursLeft > 1) {
|
|
1124
|
-
estimateLabel = hoursLeft.toFixed(1) +
|
|
1125
|
-
estimateColor = hoursLeft < 3 ?
|
|
1815
|
+
estimateLabel = hoursLeft.toFixed(1) + "h";
|
|
1816
|
+
estimateColor = hoursLeft < 3 ? "var(--yellow)" : "var(--text)";
|
|
1126
1817
|
} else {
|
|
1127
|
-
estimateLabel = Math.round(hoursLeft * 60) +
|
|
1128
|
-
estimateColor =
|
|
1818
|
+
estimateLabel = Math.round(hoursLeft * 60) + "min";
|
|
1819
|
+
estimateColor = "var(--red)";
|
|
1129
1820
|
}
|
|
1130
1821
|
}
|
|
1131
1822
|
|
|
1132
1823
|
// Quota bar
|
|
1133
|
-
var barColor =
|
|
1134
|
-
|
|
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
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
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 +=
|
|
1870
|
+
html += "</table>";
|
|
1151
1871
|
grid.innerHTML = html;
|
|
1152
1872
|
}
|
|
1153
1873
|
|
|
1154
1874
|
function renderLatencyPanel(latencyStats) {
|
|
1155
|
-
var panel = document.getElementById(
|
|
1156
|
-
var grid = document.getElementById(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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 +=
|
|
1178
|
-
'<
|
|
1179
|
-
'<td style="padding:4px 8px"
|
|
1180
|
-
|
|
1181
|
-
'
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
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 +=
|
|
1927
|
+
html += "</table>";
|
|
1188
1928
|
grid.innerHTML = html;
|
|
1189
1929
|
}
|
|
1190
1930
|
|
|
1191
1931
|
function renderRequestLog(log) {
|
|
1192
|
-
var panel = document.getElementById(
|
|
1193
|
-
var grid = document.getElementById(
|
|
1932
|
+
var panel = document.getElementById("requestLogPanel");
|
|
1933
|
+
var grid = document.getElementById("requestLogGrid");
|
|
1194
1934
|
if (!log || log.length === 0) {
|
|
1195
|
-
panel.style.display =
|
|
1935
|
+
panel.style.display = "none";
|
|
1196
1936
|
return;
|
|
1197
1937
|
}
|
|
1198
|
-
panel.style.display =
|
|
1938
|
+
panel.style.display = "";
|
|
1199
1939
|
|
|
1200
|
-
var fModel = (
|
|
1201
|
-
|
|
1202
|
-
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
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 =
|
|
1225
|
-
|
|
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 =
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
'<
|
|
1235
|
-
'<td style="padding:3px 6px
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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 +=
|
|
1242
|
-
if (filtered.length === 0)
|
|
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
|
-
[
|
|
1249
|
-
|
|
1250
|
-
|
|
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(
|
|
2056
|
+
var panel = document.getElementById("recentEventsPanel");
|
|
1272
2057
|
var allEvents = events || [];
|
|
1273
2058
|
if (allEvents.length === 0) {
|
|
1274
|
-
panel.style.display =
|
|
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
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
var rows = list
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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
|
|
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
|
|
2104
|
+
return (
|
|
2105
|
+
'<button class="event-filter' +
|
|
2106
|
+
(EVENT_FILTER === filter ? " active" : "") +
|
|
2107
|
+
'" onclick="setEventFilter("' +
|
|
2108
|
+
filter +
|
|
2109
|
+
'")">' +
|
|
2110
|
+
label +
|
|
2111
|
+
"</button>"
|
|
2112
|
+
);
|
|
1304
2113
|
}
|
|
1305
2114
|
|
|
1306
2115
|
function matchesEventFilter(event) {
|
|
1307
|
-
if (EVENT_FILTER ===
|
|
1308
|
-
if (EVENT_FILTER ===
|
|
1309
|
-
if (EVENT_FILTER ===
|
|
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(
|
|
1314
|
-
var EVENT_FILTER =
|
|
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] =
|
|
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(
|
|
1330
|
-
return masked.toLowerCase().replace(/ /g,
|
|
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,
|
|
1336
|
-
.replace(/</g,
|
|
1337
|
-
.replace(/>/g,
|
|
1338
|
-
.replace(/"/g,
|
|
1339
|
-
.replace(/'/g,
|
|
2145
|
+
.replace(/&/g, "&")
|
|
2146
|
+
.replace(/</g, "<")
|
|
2147
|
+
.replace(/>/g, ">")
|
|
2148
|
+
.replace(/"/g, """)
|
|
2149
|
+
.replace(/'/g, "'");
|
|
1340
2150
|
}
|
|
1341
2151
|
|
|
1342
2152
|
function jsString(text) {
|
|
1343
|
-
return escapeHtml(
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
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 =
|
|
1351
|
-
|
|
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 ? {
|
|
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(
|
|
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(
|
|
2191
|
+
var res = await authFetch("/api/config");
|
|
1377
2192
|
var data = await res.json();
|
|
1378
|
-
document.getElementById(
|
|
1379
|
-
document.getElementById(
|
|
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(
|
|
2199
|
+
var raw = document.getElementById("configEditor").value;
|
|
1384
2200
|
try {
|
|
1385
2201
|
var parsed = JSON.parse(raw);
|
|
1386
|
-
var res = await authFetch(
|
|
1387
|
-
method:
|
|
1388
|
-
headers: {
|
|
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)
|
|
1393
|
-
|
|
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(
|
|
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(
|
|
2222
|
+
var res = await authFetch("/api/config/export");
|
|
1402
2223
|
var data = await res.text();
|
|
1403
|
-
var blob = new Blob([data], { type:
|
|
2224
|
+
var blob = new Blob([data], { type: "application/json" });
|
|
1404
2225
|
var url = URL.createObjectURL(blob);
|
|
1405
|
-
var a = document.createElement(
|
|
2226
|
+
var a = document.createElement("a");
|
|
1406
2227
|
a.href = url;
|
|
1407
|
-
a.download =
|
|
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(
|
|
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(
|
|
1418
|
-
method:
|
|
1419
|
-
headers: {
|
|
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)
|
|
1424
|
-
|
|
1425
|
-
|
|
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(
|
|
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(
|
|
2263
|
+
openModal("configEditorModal");
|
|
1434
2264
|
await loadConfigEditor();
|
|
1435
2265
|
}
|
|
1436
2266
|
|
|
1437
2267
|
function renderRoutingInspector(data) {
|
|
1438
|
-
var panel = document.getElementById(
|
|
2268
|
+
var panel = document.getElementById("routingInspectorPanel");
|
|
1439
2269
|
if (!panel) return;
|
|
1440
|
-
var diagnostics =
|
|
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 =
|
|
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
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
var
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
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
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
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(
|
|
2381
|
+
openModal("routingInspectorModal");
|
|
1498
2382
|
renderRoutingInspector(window.__lastData || {});
|
|
1499
2383
|
}
|
|
1500
2384
|
|
|
1501
2385
|
async function enableAccount(email) {
|
|
1502
|
-
await authFetch(
|
|
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 (
|
|
1508
|
-
|
|
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 (
|
|
1514
|
-
|
|
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(
|
|
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(
|
|
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();
|
|
1528
|
-
|
|
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(
|
|
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 =
|
|
1539
|
-
|
|
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 !==
|
|
2458
|
+
var isOpen = dropdown.style.display !== "none";
|
|
1545
2459
|
// Close all other dropdowns first
|
|
1546
|
-
document.querySelectorAll(
|
|
2460
|
+
document.querySelectorAll(".tier-dropdown").forEach(function (d) {
|
|
2461
|
+
d.style.display = "none";
|
|
2462
|
+
});
|
|
1547
2463
|
if (!isOpen) {
|
|
1548
|
-
dropdown.style.display =
|
|
2464
|
+
dropdown.style.display = "block";
|
|
1549
2465
|
// Close on click outside
|
|
1550
|
-
setTimeout(function() {
|
|
1551
|
-
document.addEventListener(
|
|
2466
|
+
setTimeout(function () {
|
|
2467
|
+
document.addEventListener("click", function closeDropdown(e) {
|
|
1552
2468
|
if (!dropdown.contains(e.target) && e.target !== badge) {
|
|
1553
|
-
dropdown.style.display =
|
|
1554
|
-
document.removeEventListener(
|
|
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(
|
|
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();
|
|
1566
|
-
|
|
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(
|
|
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(
|
|
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 (
|
|
1586
|
-
|
|
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 (
|
|
1593
|
-
|
|
1594
|
-
|
|
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(
|
|
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(
|
|
2609
|
+
if (modal) modal.classList.remove("open");
|
|
1609
2610
|
}
|
|
1610
2611
|
|
|
1611
2612
|
async function refresh() {
|
|
1612
2613
|
try {
|
|
1613
|
-
var res = await authFetch(
|
|
2614
|
+
var res = await authFetch("/api/status");
|
|
1614
2615
|
var data = await res.json();
|
|
1615
2616
|
renderAccounts(data);
|
|
1616
|
-
var btn = document.getElementById(
|
|
1617
|
-
if (btn) btn.textContent = MASK_MODE ?
|
|
2617
|
+
var btn = document.getElementById("maskBtn");
|
|
2618
|
+
if (btn) btn.textContent = MASK_MODE ? "PII: Hidden" : "PII: Visible";
|
|
1618
2619
|
} catch (err) {
|
|
1619
|
-
console.error(
|
|
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(
|
|
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) {
|
|
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(
|
|
2648
|
+
url.searchParams.delete("mask");
|
|
1646
2649
|
} else {
|
|
1647
|
-
url.searchParams.set(
|
|
2650
|
+
url.searchParams.set("mask", "1");
|
|
1648
2651
|
}
|
|
1649
2652
|
window.location.href = url.toString();
|
|
1650
2653
|
}
|
|
1651
2654
|
|
|
1652
|
-
document.addEventListener(
|
|
1653
|
-
if (event.key ===
|
|
1654
|
-
closeModal(null,
|
|
1655
|
-
closeModal(null,
|
|
1656
|
-
closeModal(null,
|
|
1657
|
-
closeModal(null,
|
|
1658
|
-
closeModal(null,
|
|
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(
|
|
1664
|
-
closeModal(null,
|
|
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(
|
|
1672
|
-
setTimeout(function() {
|
|
1673
|
-
openModal(
|
|
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(
|
|
1680
|
-
var message = document.getElementById(
|
|
1681
|
-
var actions = document.getElementById(
|
|
1682
|
-
var badgeLabel = document.getElementById(
|
|
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(
|
|
2690
|
+
var pendingRestart = localStorage.getItem("updatePendingRestart");
|
|
1688
2691
|
if (pendingRestart) {
|
|
1689
|
-
banner.className =
|
|
1690
|
-
badgeLabel.textContent =
|
|
1691
|
-
message.innerHTML =
|
|
1692
|
-
|
|
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 =
|
|
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(
|
|
2709
|
+
var dismissed = localStorage.getItem("updateDismissed");
|
|
1703
2710
|
if (dismissed === updateInfo.latestVersion) {
|
|
1704
|
-
banner.className =
|
|
2711
|
+
banner.className = "update-banner";
|
|
1705
2712
|
return;
|
|
1706
2713
|
}
|
|
1707
2714
|
|
|
1708
|
-
banner.className =
|
|
1709
|
-
badgeLabel.textContent =
|
|
1710
|
-
message.innerHTML =
|
|
1711
|
-
|
|
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(\'' +
|
|
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(
|
|
2733
|
+
var btn = document.getElementById("btnDoUpdate");
|
|
1720
2734
|
if (btn) {
|
|
1721
2735
|
btn.disabled = true;
|
|
1722
|
-
btn.textContent =
|
|
2736
|
+
btn.textContent = "Updating...";
|
|
1723
2737
|
}
|
|
1724
2738
|
try {
|
|
1725
|
-
var res = await authFetch(
|
|
2739
|
+
var res = await authFetch("/api/self-update", { method: "POST" });
|
|
1726
2740
|
var result = await res.json();
|
|
1727
|
-
var banner = document.getElementById(
|
|
1728
|
-
var message = document.getElementById(
|
|
1729
|
-
var actions = document.getElementById(
|
|
1730
|
-
var badgeLabel = document.getElementById(
|
|
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(
|
|
1733
|
-
banner.className =
|
|
1734
|
-
badgeLabel.textContent =
|
|
1735
|
-
message.innerHTML =
|
|
1736
|
-
|
|
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 =
|
|
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 =
|
|
2761
|
+
btn.textContent = "Retry";
|
|
1742
2762
|
}
|
|
1743
2763
|
}
|
|
1744
2764
|
} catch (err) {
|
|
1745
|
-
var message2 = document.getElementById(
|
|
1746
|
-
if (message2)
|
|
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 =
|
|
2772
|
+
btn.textContent = "Retry";
|
|
1750
2773
|
}
|
|
1751
2774
|
}
|
|
1752
2775
|
}
|
|
1753
2776
|
|
|
1754
2777
|
function dismissUpdate(version) {
|
|
1755
|
-
localStorage.setItem(
|
|
1756
|
-
var banner = document.getElementById(
|
|
1757
|
-
if (banner) banner.className =
|
|
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(
|
|
1762
|
-
var banner = document.getElementById(
|
|
1763
|
-
if (banner) banner.className =
|
|
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:
|
|
2790
|
+
var NOTIF_ICONS = { info: "ℹ️", warning: "⚠️", critical: "🚨" };
|
|
1768
2791
|
|
|
1769
2792
|
function renderNotifications(notifications) {
|
|
1770
|
-
var container = document.getElementById(
|
|
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(
|
|
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 =
|
|
1787
|
-
html +=
|
|
1788
|
-
|
|
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) +
|
|
1791
|
-
html += '<div class="notif-msg">' + escapeHtml(n.message) +
|
|
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 +=
|
|
1795
|
-
|
|
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 +=
|
|
1798
|
-
html +=
|
|
1799
|
-
|
|
2830
|
+
html += "</div>";
|
|
2831
|
+
html +=
|
|
2832
|
+
'<button class="notif-dismiss" onclick="dismissNotification(\'' +
|
|
2833
|
+
escapeHtml(n.id) +
|
|
2834
|
+
'\')" title="Dismiss">×</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(
|
|
1807
|
-
var el = document.getElementById(
|
|
2842
|
+
localStorage.setItem("notif-dismissed-" + id, "1");
|
|
2843
|
+
var el = document.getElementById("notif-" + id);
|
|
1808
2844
|
if (el) {
|
|
1809
|
-
el.style.opacity =
|
|
1810
|
-
el.style.transform =
|
|
1811
|
-
el.style.transition =
|
|
1812
|
-
setTimeout(function() {
|
|
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(
|
|
2853
|
+
var container = document.getElementById("notifContainer");
|
|
1816
2854
|
if (container) {
|
|
1817
|
-
var remaining = container.querySelectorAll(
|
|
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(
|
|
2862
|
+
var bellBtn = document.querySelector(".header-icon-btn.attention");
|
|
1825
2863
|
if (!bellBtn) return;
|
|
1826
|
-
var badge = bellBtn.querySelector(
|
|
2864
|
+
var badge = bellBtn.querySelector(".header-icon-badge");
|
|
1827
2865
|
if (count > 0) {
|
|
1828
|
-
bellBtn.classList.add(
|
|
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
|
}
|