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