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