pi-antigravity-rotator 1.12.0 → 1.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/package.json +1 -1
- package/src/compat.ts +118 -11
- package/src/dashboard.ts +387 -1
- package/src/proxy.ts +20 -6
- package/src/rotator.ts +5 -0
- package/src/types.ts +2 -0
- package/tools/telemetry-receiver/receiver.js +274 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.12.2] - 2026-05-18
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Gemini Flash/Pro regression (`INVALID_ARGUMENT`)**: The `id` field added to `functionCall` and `functionResponse` history parts for Claude multi-turn support was also being sent to Gemini native models, which reject it. The field is now only included when the model is Claude (`/^claude-/i`).
|
|
7
|
+
|
|
8
|
+
## [1.12.1] - 2026-05-18
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Claude tool schema compatibility (JSON Schema Draft 2020-12)**: When routing requests to Claude models (`claude-sonnet-4-6`, `claude-opus-4-6-thinking`) through Gemini's API, a new `sanitizeClaudeViaGeminiSchema` function is used instead of the Gemini-native sanitizer. It only removes fields that Gemini's outer API layer rejects (e.g. `$ref`, `$defs`, `if/then/else`) and converts `const` → `enum`, while preserving valid Draft 2020-12 keywords (`minimum`, `maximum`, `pattern`, `minLength`, `title`, `default`, etc.) that Claude requires.
|
|
12
|
+
- **Claude `anyOf [{type,const}]` → flat `enum` collapse**: Schemas with `anyOf` items of the form `[{"type":"string","const":"fact"},{"type":"string","const":"lesson"}]` are now correctly collapsed into `{"type":"string","enum":["fact","lesson"]}`. Previously this produced a redundant `anyOf` with single-element enums that Claude rejected as invalid.
|
|
13
|
+
- **Claude multi-turn tool call IDs (`tool_use.id: Field required`)**: When replaying tool-call history for Claude models, the OpenAI tool call `id` (e.g. `call_xxx`) is now included in the Gemini `functionCall.id` field, and the `tool_call_id` from tool response messages is included in the Gemini `functionResponse.id` field. Gemini passes these through to Claude as `tool_use.id` / `tool_use_id`, fixing the "Field required" error on multi-turn agentic conversations.
|
|
14
|
+
|
|
3
15
|
## [1.12.0] - 2026-05-17
|
|
4
16
|
|
|
17
|
+
|
|
5
18
|
### Added
|
|
6
19
|
- **Native Reasoning/Thinking Support**: Interleaved thinking blocks from Gemini 3.1 Pro, Gemini 3 Flash, and Claude models are now properly exposed to OpenAI and Anthropic compatible clients as `reasoning_content` and `thinking_delta` chunks.
|
|
7
20
|
- **Model & Project Circuit Breaker Reset**: Added manual reset buttons on the dashboard for all circuit breakers, allowing operators to bypass the cooldown period when desired.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-antigravity-rotator",
|
|
3
|
-
"version": "1.12.
|
|
3
|
+
"version": "1.12.2",
|
|
4
4
|
"description": "Multi-account rotation proxy for Google Antigravity with per-model routing, real-time quota tracking, and infringement detection",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/compat.ts
CHANGED
|
@@ -234,15 +234,116 @@ function sanitizeGeminiSchema(schema: unknown): unknown {
|
|
|
234
234
|
return out;
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Lighter sanitization for Claude models routed through Gemini's API.
|
|
239
|
+
* Gemini's outer API still validates schemas before routing to Claude, so
|
|
240
|
+
* we must remove fields Gemini's protobuf doesn't know about (like `const`,
|
|
241
|
+
* `$ref`, etc.). However, unlike the Gemini-native sanitizer, we KEEP
|
|
242
|
+
* standard JSON Schema Draft 2020-12 keywords (minimum, maximum, pattern,
|
|
243
|
+
* etc.) that Claude requires and that Gemini's API does pass through.
|
|
244
|
+
*/
|
|
245
|
+
function sanitizeClaudeViaGeminiSchema(schema: unknown): unknown {
|
|
246
|
+
if (!isRecord(schema)) return schema;
|
|
247
|
+
|
|
248
|
+
// Only remove fields that Gemini's API layer truly rejects at the network level.
|
|
249
|
+
// We keep Draft 2020-12 keywords like minimum/maximum/pattern/title/etc.
|
|
250
|
+
const UNSUPPORTED = new Set([
|
|
251
|
+
"$schema", "$id", "$ref", "$defs", "definitions",
|
|
252
|
+
"if", "then", "else", "not",
|
|
253
|
+
"patternProperties", "unevaluatedProperties", "unevaluatedItems",
|
|
254
|
+
"contentEncoding", "contentMediaType",
|
|
255
|
+
]);
|
|
256
|
+
|
|
257
|
+
const out: Record<string, unknown> = {};
|
|
258
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
259
|
+
if (UNSUPPORTED.has(key)) continue;
|
|
260
|
+
|
|
261
|
+
// `const` is not supported by Gemini's API — convert to a single-value enum
|
|
262
|
+
if (key === "const") {
|
|
263
|
+
out["enum"] = [value];
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (key === "anyOf" || key === "oneOf" || key === "allOf") {
|
|
268
|
+
if (Array.isArray(value)) {
|
|
269
|
+
// Case 1: all items are pure {const: value} — convert to flat enum.
|
|
270
|
+
const allPureConst = value.every(
|
|
271
|
+
(item) => isRecord(item) && Object.keys(item).length === 1 && "const" in item,
|
|
272
|
+
);
|
|
273
|
+
if (allPureConst) {
|
|
274
|
+
out["enum"] = value.map((item) => (item as Record<string, unknown>)["const"]);
|
|
275
|
+
if (value.every((item) => typeof (item as Record<string, unknown>)["const"] === "string")) {
|
|
276
|
+
if (!out["type"]) out["type"] = "string";
|
|
277
|
+
}
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Case 2: all items are {type: T, const: V} (same type, each with a const).
|
|
282
|
+
// e.g. [{type:"string",const:"fact"},{type:"string",const:"lesson"}]
|
|
283
|
+
// Merge into a single flat {type: T, enum: [V1, V2, ...]} — avoids
|
|
284
|
+
// the redundant anyOf-with-single-enum pattern that Claude rejects.
|
|
285
|
+
const allTypeConst = value.every(
|
|
286
|
+
(item) =>
|
|
287
|
+
isRecord(item) &&
|
|
288
|
+
Object.keys(item).length === 2 &&
|
|
289
|
+
"type" in item &&
|
|
290
|
+
"const" in item,
|
|
291
|
+
);
|
|
292
|
+
if (allTypeConst) {
|
|
293
|
+
const firstType = (value[0] as Record<string, unknown>)["type"];
|
|
294
|
+
const allSameType = value.every((item) => (item as Record<string, unknown>)["type"] === firstType);
|
|
295
|
+
if (allSameType) {
|
|
296
|
+
if (!out["type"]) out["type"] = firstType;
|
|
297
|
+
out["enum"] = value.map((item) => (item as Record<string, unknown>)["const"]);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// General case: recurse and sanitize each variant.
|
|
303
|
+
const cleaned = value.map(sanitizeClaudeViaGeminiSchema).filter(
|
|
304
|
+
(v) => isRecord(v) && Object.keys(v).length > 0,
|
|
305
|
+
);
|
|
306
|
+
if (cleaned.length === 1) {
|
|
307
|
+
Object.assign(out, cleaned[0]);
|
|
308
|
+
} else if (cleaned.length > 1) {
|
|
309
|
+
out[key] = cleaned;
|
|
310
|
+
}
|
|
311
|
+
// cleaned.length === 0: skip entirely
|
|
312
|
+
}
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (key === "properties" && isRecord(value)) {
|
|
317
|
+
out[key] = Object.fromEntries(
|
|
318
|
+
Object.entries(value).map(([k, v]) => [k, sanitizeClaudeViaGeminiSchema(v)]),
|
|
319
|
+
);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (key === "items") {
|
|
324
|
+
out[key] = sanitizeClaudeViaGeminiSchema(value);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
out[key] = isRecord(value) ? sanitizeClaudeViaGeminiSchema(value) : value;
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
237
333
|
/** Convert OpenAI tools array to Gemini functionDeclarations */
|
|
238
|
-
function convertOpenAIToolsToGemini(tools: OpenAITool[]): { functionDeclarations: GeminiFunctionDeclaration[] }[] {
|
|
334
|
+
function convertOpenAIToolsToGemini(tools: OpenAITool[], isClaude: boolean = false): { functionDeclarations: GeminiFunctionDeclaration[] }[] {
|
|
239
335
|
const decls: GeminiFunctionDeclaration[] = tools
|
|
240
336
|
.filter((t) => t.type === "function" && isNonEmptyString(t.function?.name))
|
|
241
|
-
.map((t) =>
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
337
|
+
.map((t) => {
|
|
338
|
+
const sanitized = t.function.parameters
|
|
339
|
+
? (isClaude ? sanitizeClaudeViaGeminiSchema(t.function.parameters) : sanitizeGeminiSchema(t.function.parameters)) as Record<string, unknown>
|
|
340
|
+
: undefined;
|
|
341
|
+
return {
|
|
342
|
+
name: t.function.name,
|
|
343
|
+
...(t.function.description ? { description: t.function.description } : {}),
|
|
344
|
+
...(sanitized ? { parameters: sanitized } : {}),
|
|
345
|
+
};
|
|
346
|
+
});
|
|
246
347
|
return decls.length > 0 ? [{ functionDeclarations: decls }] : [];
|
|
247
348
|
}
|
|
248
349
|
|
|
@@ -307,6 +408,9 @@ export function openAIToAntigravityBody(input: OpenAIChatCompletionRequest): Req
|
|
|
307
408
|
// part when replaying multi-turn tool conversations. Since we receive
|
|
308
409
|
// We always use native Gemini functionCall parts for all tool calls in the history.
|
|
309
410
|
|
|
411
|
+
// Determine if model is Claude — affects schema sanitization and tool call ID handling
|
|
412
|
+
const isClaude = /^claude-/i.test(input.model);
|
|
413
|
+
|
|
310
414
|
const contents: GeminiContent[] = [];
|
|
311
415
|
for (let i = 0; i < conversationMessages.length; i++) {
|
|
312
416
|
const msg = conversationMessages[i];
|
|
@@ -329,13 +433,14 @@ export function openAIToAntigravityBody(input: OpenAIChatCompletionRequest): Req
|
|
|
329
433
|
const cachedSig = isFirstInMessage ? thoughtSignatureCache.get(tc.id) : undefined;
|
|
330
434
|
parts.push({
|
|
331
435
|
...(cachedSig ? { thoughtSignature: cachedSig } : {}),
|
|
332
|
-
|
|
436
|
+
// Include id only for Claude — Gemini native models reject the id field
|
|
437
|
+
functionCall: { ...(isClaude ? { id: tc.id } : {}), name: tc.function.name, args },
|
|
333
438
|
});
|
|
334
439
|
} catch {
|
|
335
440
|
const cachedSig = isFirstInMessage ? thoughtSignatureCache.get(tc.id) : undefined;
|
|
336
441
|
parts.push({
|
|
337
442
|
...(cachedSig ? { thoughtSignature: cachedSig } : {}),
|
|
338
|
-
functionCall: { name: tc.function.name, args: {} },
|
|
443
|
+
functionCall: { ...(isClaude ? { id: tc.id } : {}), name: tc.function.name, args: {} },
|
|
339
444
|
});
|
|
340
445
|
}
|
|
341
446
|
isFirstInMessage = false;
|
|
@@ -346,10 +451,12 @@ export function openAIToAntigravityBody(input: OpenAIChatCompletionRequest): Req
|
|
|
346
451
|
const prevMsg = conversationMessages[i - 1];
|
|
347
452
|
const responseText = typeof msg.content === "string" ? msg.content : extractText(msg.content);
|
|
348
453
|
const fnName = msg.name || "unknown";
|
|
349
|
-
//
|
|
454
|
+
// Include tool_call_id so Gemini can pass it as tool_use_id to Claude
|
|
455
|
+
const toolCallId = msg.tool_call_id;
|
|
350
456
|
let responseData: unknown;
|
|
351
457
|
try { responseData = JSON.parse(responseText); } catch { responseData = { output: responseText }; }
|
|
352
|
-
|
|
458
|
+
// Include id only for Claude — Gemini native models reject the id field in functionResponse
|
|
459
|
+
contents.push({ role: "user", parts: [{ functionResponse: { ...(isClaude && toolCallId ? { id: toolCallId } : {}), name: fnName, response: responseData } }] });
|
|
353
460
|
} else {
|
|
354
461
|
// user message
|
|
355
462
|
const msgParts = extractParts(msg.content);
|
|
@@ -362,7 +469,7 @@ export function openAIToAntigravityBody(input: OpenAIChatCompletionRequest): Req
|
|
|
362
469
|
|
|
363
470
|
// Build tools / toolConfig if present
|
|
364
471
|
const inputTools = Array.isArray(input.tools) ? (input.tools as OpenAITool[]) : [];
|
|
365
|
-
const geminiTools = convertOpenAIToolsToGemini(inputTools);
|
|
472
|
+
const geminiTools = convertOpenAIToolsToGemini(inputTools, isClaude);
|
|
366
473
|
const geminiToolConfig = input.tool_choice !== undefined ? convertToolChoiceToGemini(input.tool_choice) : undefined;
|
|
367
474
|
|
|
368
475
|
// Map OpenAI reasoning_effort → Gemini thinkingLevel
|
package/src/dashboard.ts
CHANGED
|
@@ -1168,6 +1168,192 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
|
1168
1168
|
.notif-bell-dot {
|
|
1169
1169
|
background: var(--yellow);
|
|
1170
1170
|
}
|
|
1171
|
+
|
|
1172
|
+
/* ── List View ── */
|
|
1173
|
+
.view-toggle-bar {
|
|
1174
|
+
display: flex;
|
|
1175
|
+
align-items: center;
|
|
1176
|
+
gap: 8px;
|
|
1177
|
+
margin-bottom: 16px;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
.view-tab {
|
|
1181
|
+
font-size: 12px;
|
|
1182
|
+
font-weight: 600;
|
|
1183
|
+
padding: 5px 14px;
|
|
1184
|
+
border-radius: 999px;
|
|
1185
|
+
border: 1px solid var(--border);
|
|
1186
|
+
background: transparent;
|
|
1187
|
+
color: var(--text-dim);
|
|
1188
|
+
cursor: pointer;
|
|
1189
|
+
font-family: var(--font);
|
|
1190
|
+
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
.view-tab.active {
|
|
1194
|
+
background: rgba(124, 92, 252, 0.14);
|
|
1195
|
+
border-color: rgba(124, 92, 252, 0.35);
|
|
1196
|
+
color: var(--text);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
.view-tab:hover:not(.active) {
|
|
1200
|
+
background: rgba(255,255,255,0.04);
|
|
1201
|
+
color: var(--text);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
.list-panel {
|
|
1205
|
+
background: var(--surface);
|
|
1206
|
+
border: 1px solid var(--border);
|
|
1207
|
+
border-radius: var(--radius);
|
|
1208
|
+
overflow: hidden;
|
|
1209
|
+
margin-bottom: 24px;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
.list-toolbar {
|
|
1213
|
+
display: flex;
|
|
1214
|
+
align-items: center;
|
|
1215
|
+
gap: 10px;
|
|
1216
|
+
padding: 12px 16px;
|
|
1217
|
+
border-bottom: 1px solid var(--border);
|
|
1218
|
+
flex-wrap: wrap;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
.list-toolbar-label {
|
|
1222
|
+
font-size: 11px;
|
|
1223
|
+
text-transform: uppercase;
|
|
1224
|
+
letter-spacing: 0.6px;
|
|
1225
|
+
color: var(--text-dim);
|
|
1226
|
+
margin-right: auto;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
.list-search {
|
|
1230
|
+
background: rgba(255,255,255,0.04);
|
|
1231
|
+
border: 1px solid var(--border);
|
|
1232
|
+
color: var(--text);
|
|
1233
|
+
padding: 4px 10px;
|
|
1234
|
+
border-radius: 6px;
|
|
1235
|
+
font-size: 12px;
|
|
1236
|
+
font-family: var(--font);
|
|
1237
|
+
width: 180px;
|
|
1238
|
+
outline: none;
|
|
1239
|
+
transition: border-color 0.2s;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
.list-search:focus {
|
|
1243
|
+
border-color: rgba(124, 92, 252, 0.4);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
.list-sort-btn {
|
|
1247
|
+
font-size: 11px;
|
|
1248
|
+
padding: 4px 10px;
|
|
1249
|
+
border: 1px solid var(--border);
|
|
1250
|
+
background: transparent;
|
|
1251
|
+
color: var(--text-dim);
|
|
1252
|
+
border-radius: 6px;
|
|
1253
|
+
cursor: pointer;
|
|
1254
|
+
font-family: var(--font);
|
|
1255
|
+
font-weight: 600;
|
|
1256
|
+
transition: background 0.2s, color 0.2s;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
.list-sort-btn.active {
|
|
1260
|
+
border-color: rgba(124, 92, 252, 0.35);
|
|
1261
|
+
color: var(--accent);
|
|
1262
|
+
background: rgba(124, 92, 252, 0.08);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
.list-table {
|
|
1266
|
+
width: 100%;
|
|
1267
|
+
border-collapse: collapse;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
.list-table th {
|
|
1271
|
+
font-size: 10px;
|
|
1272
|
+
font-weight: 700;
|
|
1273
|
+
text-transform: uppercase;
|
|
1274
|
+
letter-spacing: 0.5px;
|
|
1275
|
+
color: var(--text-dim);
|
|
1276
|
+
padding: 8px 14px;
|
|
1277
|
+
text-align: left;
|
|
1278
|
+
border-bottom: 1px solid var(--border);
|
|
1279
|
+
background: rgba(255,255,255,0.02);
|
|
1280
|
+
white-space: nowrap;
|
|
1281
|
+
cursor: pointer;
|
|
1282
|
+
user-select: none;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
.list-table th:hover { color: var(--text); }
|
|
1286
|
+
|
|
1287
|
+
.list-table th .sort-arrow {
|
|
1288
|
+
display: inline-block;
|
|
1289
|
+
margin-left: 4px;
|
|
1290
|
+
opacity: 0.4;
|
|
1291
|
+
font-size: 9px;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
.list-table th.sort-active .sort-arrow { opacity: 1; color: var(--accent); }
|
|
1295
|
+
|
|
1296
|
+
.list-table td {
|
|
1297
|
+
padding: 9px 14px;
|
|
1298
|
+
font-size: 12px;
|
|
1299
|
+
border-bottom: 1px solid rgba(255,255,255,0.04);
|
|
1300
|
+
vertical-align: middle;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
.list-table tr:last-child td { border-bottom: none; }
|
|
1304
|
+
|
|
1305
|
+
.list-table tr.list-row {
|
|
1306
|
+
cursor: pointer;
|
|
1307
|
+
transition: background 0.15s;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
.list-table tr.list-row:hover td {
|
|
1311
|
+
background: rgba(124, 92, 252, 0.05);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
.list-row-label {
|
|
1315
|
+
font-weight: 600;
|
|
1316
|
+
font-size: 13px;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
.list-row-email {
|
|
1320
|
+
font-family: 'JetBrains Mono', monospace;
|
|
1321
|
+
font-size: 10px;
|
|
1322
|
+
color: var(--text-dim);
|
|
1323
|
+
margin-top: 2px;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
.list-quota-bar {
|
|
1327
|
+
display: flex;
|
|
1328
|
+
align-items: center;
|
|
1329
|
+
gap: 6px;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
.list-quota-bar-bg {
|
|
1333
|
+
width: 60px;
|
|
1334
|
+
height: 5px;
|
|
1335
|
+
background: rgba(255,255,255,0.07);
|
|
1336
|
+
border-radius: 3px;
|
|
1337
|
+
overflow: hidden;
|
|
1338
|
+
flex-shrink: 0;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
.list-quota-bar-fill {
|
|
1342
|
+
height: 100%;
|
|
1343
|
+
border-radius: 3px;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
.list-highlight td {
|
|
1347
|
+
background: rgba(124, 92, 252, 0.12) !important;
|
|
1348
|
+
transition: background 0.8s ease-out !important;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
.list-empty {
|
|
1352
|
+
text-align: center;
|
|
1353
|
+
color: var(--text-dim);
|
|
1354
|
+
padding: 32px;
|
|
1355
|
+
font-size: 13px;
|
|
1356
|
+
}
|
|
1171
1357
|
</style>
|
|
1172
1358
|
</head>
|
|
1173
1359
|
<body>
|
|
@@ -1210,6 +1396,11 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
|
1210
1396
|
</div>
|
|
1211
1397
|
</div>
|
|
1212
1398
|
|
|
1399
|
+
<div class="view-toggle-bar">
|
|
1400
|
+
<button class="view-tab active" id="viewTabGrid" onclick="switchView('grid')">⊞ Grid</button>
|
|
1401
|
+
<button class="view-tab" id="viewTabList" onclick="switchView('list')">☰ List</button>
|
|
1402
|
+
</div>
|
|
1403
|
+
|
|
1213
1404
|
<div class="routing-panel state-stopped" id="routingHealth"></div>
|
|
1214
1405
|
|
|
1215
1406
|
<div class="routing-panel" id="tokenUsagePanel" style="margin-top:12px">
|
|
@@ -1246,6 +1437,18 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
|
1246
1437
|
|
|
1247
1438
|
<div class="accounts-grid" id="accounts"></div>
|
|
1248
1439
|
|
|
1440
|
+
<div class="list-panel" id="listPanel" style="display:none">
|
|
1441
|
+
<div class="list-toolbar">
|
|
1442
|
+
<span class="list-toolbar-label">Installations</span>
|
|
1443
|
+
<input class="list-search" id="listSearch" placeholder="Search…" oninput="renderListView()" />
|
|
1444
|
+
<button class="list-sort-btn" id="lsort-requests" onclick="setListSort('requests')">Requests ↕</button>
|
|
1445
|
+
<button class="list-sort-btn" id="lsort-quota" onclick="setListSort('quota')">Quota ↕</button>
|
|
1446
|
+
<button class="list-sort-btn" id="lsort-tokens" onclick="setListSort('tokens')">Tokens ↕</button>
|
|
1447
|
+
<button class="list-sort-btn" id="lsort-status" onclick="setListSort('status')">Status ↕</button>
|
|
1448
|
+
</div>
|
|
1449
|
+
<div id="listTableWrap"></div>
|
|
1450
|
+
</div>
|
|
1451
|
+
|
|
1249
1452
|
<div class="routing-panel" id="heatmapPanel" style="margin-top:12px;display:none">
|
|
1250
1453
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
|
|
1251
1454
|
<strong>Activity Heatmap (last 60d)</strong>
|
|
@@ -1602,7 +1805,7 @@ function renderAccounts(data) {
|
|
|
1602
1805
|
return '<span class="badge badge-model">' + escapeHtml(m.split('-').slice(0, 2).join('-')) + '</span>';
|
|
1603
1806
|
}).join('');
|
|
1604
1807
|
|
|
1605
|
-
return '<div class="account-card ' + escapeHtml(a.status) + '">' +
|
|
1808
|
+
return '<div class="account-card ' + escapeHtml(a.status) + '" data-account-email="' + escapeHtml(a.email) + '">' +
|
|
1606
1809
|
'<div class="card-header">' +
|
|
1607
1810
|
'<div class="card-label">' + escapeHtml(maskText(a.label)) + '</div>' +
|
|
1608
1811
|
'<div class="card-badges">' +
|
|
@@ -1651,6 +1854,189 @@ function renderAccounts(data) {
|
|
|
1651
1854
|
renderProAdvisor(data.proAdvisor);
|
|
1652
1855
|
}
|
|
1653
1856
|
|
|
1857
|
+
|
|
1858
|
+
// ── List View ─────────────────────────────────────────────────────────────
|
|
1859
|
+
var CURRENT_VIEW = 'grid';
|
|
1860
|
+
var LIST_SORT = 'requests';
|
|
1861
|
+
var LIST_SORT_DIR = -1; // -1 = desc, 1 = asc
|
|
1862
|
+
|
|
1863
|
+
function switchView(view) {
|
|
1864
|
+
CURRENT_VIEW = view;
|
|
1865
|
+
document.getElementById('viewTabGrid').className = 'view-tab' + (view === 'grid' ? ' active' : '');
|
|
1866
|
+
document.getElementById('viewTabList').className = 'view-tab' + (view === 'list' ? ' active' : '');
|
|
1867
|
+
document.getElementById('accounts').style.display = view === 'grid' ? '' : 'none';
|
|
1868
|
+
document.getElementById('listPanel').style.display = view === 'list' ? '' : 'none';
|
|
1869
|
+
if (view === 'list' && window.__lastData) renderListView();
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
function setListSort(col) {
|
|
1873
|
+
if (LIST_SORT === col) {
|
|
1874
|
+
LIST_SORT_DIR = -LIST_SORT_DIR;
|
|
1875
|
+
} else {
|
|
1876
|
+
LIST_SORT = col;
|
|
1877
|
+
LIST_SORT_DIR = -1;
|
|
1878
|
+
}
|
|
1879
|
+
['requests','quota','tokens','status'].forEach(function(c) {
|
|
1880
|
+
var btn = document.getElementById('lsort-' + c);
|
|
1881
|
+
if (btn) btn.className = 'list-sort-btn' + (c === LIST_SORT ? ' active' : '');
|
|
1882
|
+
});
|
|
1883
|
+
renderListView();
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
function renderListView() {
|
|
1887
|
+
if (!window.__lastData) return;
|
|
1888
|
+
var data = window.__lastData;
|
|
1889
|
+
var wrap = document.getElementById('listTableWrap');
|
|
1890
|
+
var query = ((document.getElementById('listSearch') || {}).value || '').toLowerCase();
|
|
1891
|
+
|
|
1892
|
+
var rows = data.accounts.slice();
|
|
1893
|
+
|
|
1894
|
+
// Filter by search
|
|
1895
|
+
if (query) {
|
|
1896
|
+
rows = rows.filter(function(a) {
|
|
1897
|
+
return (a.label || '').toLowerCase().indexOf(query) !== -1 ||
|
|
1898
|
+
(a.email || '').toLowerCase().indexOf(query) !== -1 ||
|
|
1899
|
+
(a.status || '').toLowerCase().indexOf(query) !== -1;
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
// Aggregate token totals per account (from tokensByAccount in usage data if present,
|
|
1904
|
+
// otherwise fall back to account-level totalTokens if server exposes it)
|
|
1905
|
+
var tokensByAccount = {};
|
|
1906
|
+
var tokenUsage = data.tokenUsage || {};
|
|
1907
|
+
['minutes','hours','days','months'].forEach(function(tier) {
|
|
1908
|
+
(tokenUsage[tier] || []).forEach(function(b) {
|
|
1909
|
+
if (!b.byAccount) return;
|
|
1910
|
+
Object.keys(b.byAccount).forEach(function(acct) {
|
|
1911
|
+
var d = b.byAccount[acct] || {};
|
|
1912
|
+
if (!tokensByAccount[acct]) tokensByAccount[acct] = { input: 0, output: 0 };
|
|
1913
|
+
tokensByAccount[acct].input += d.inputTokens || 0;
|
|
1914
|
+
tokensByAccount[acct].output += d.outputTokens || 0;
|
|
1915
|
+
});
|
|
1916
|
+
});
|
|
1917
|
+
});
|
|
1918
|
+
|
|
1919
|
+
// Fallback: use per-account token fields exposed directly on the account object
|
|
1920
|
+
rows.forEach(function(a) {
|
|
1921
|
+
if (!tokensByAccount[a.email] && (a.totalInputTokens || a.totalOutputTokens)) {
|
|
1922
|
+
tokensByAccount[a.email] = {
|
|
1923
|
+
input: a.totalInputTokens || 0,
|
|
1924
|
+
output: a.totalOutputTokens || 0
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
});
|
|
1928
|
+
|
|
1929
|
+
// Sort
|
|
1930
|
+
rows.sort(function(a, b) {
|
|
1931
|
+
var av, bv;
|
|
1932
|
+
if (LIST_SORT === 'requests') {
|
|
1933
|
+
av = a.totalRequests || 0;
|
|
1934
|
+
bv = b.totalRequests || 0;
|
|
1935
|
+
} else if (LIST_SORT === 'quota') {
|
|
1936
|
+
av = a.quota && a.quota.length ? a.quota.reduce(function(s, q) { return s + q.percentRemaining; }, 0) / a.quota.length : -1;
|
|
1937
|
+
bv = b.quota && b.quota.length ? b.quota.reduce(function(s, q) { return s + q.percentRemaining; }, 0) / b.quota.length : -1;
|
|
1938
|
+
} else if (LIST_SORT === 'tokens') {
|
|
1939
|
+
var ta = tokensByAccount[a.email] || { input: 0, output: 0 };
|
|
1940
|
+
var tb = tokensByAccount[b.email] || { input: 0, output: 0 };
|
|
1941
|
+
av = ta.input + ta.output;
|
|
1942
|
+
bv = tb.input + tb.output;
|
|
1943
|
+
} else if (LIST_SORT === 'status') {
|
|
1944
|
+
var statusOrder = { active: 0, ready: 1, cooldown: 2, exhausted: 3, error: 4, disabled: 5, flagged: 6 };
|
|
1945
|
+
av = statusOrder[a.status] !== undefined ? statusOrder[a.status] : 9;
|
|
1946
|
+
bv = statusOrder[b.status] !== undefined ? statusOrder[b.status] : 9;
|
|
1947
|
+
} else {
|
|
1948
|
+
av = 0; bv = 0;
|
|
1949
|
+
}
|
|
1950
|
+
if (av < bv) return LIST_SORT_DIR;
|
|
1951
|
+
if (av > bv) return -LIST_SORT_DIR;
|
|
1952
|
+
return 0;
|
|
1953
|
+
});
|
|
1954
|
+
|
|
1955
|
+
if (rows.length === 0) {
|
|
1956
|
+
wrap.innerHTML = '<div class="list-empty">No accounts match the filter.</div>';
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
var arrowFor = function(col) {
|
|
1961
|
+
if (LIST_SORT !== col) return '<span class="sort-arrow">\u2195</span>';
|
|
1962
|
+
return '<span class="sort-arrow">' + (LIST_SORT_DIR === -1 ? '\u2193' : '\u2191') + '</span>';
|
|
1963
|
+
};
|
|
1964
|
+
|
|
1965
|
+
var html = '<table class="list-table"><thead><tr>' +
|
|
1966
|
+
'<th>Account</th>' +
|
|
1967
|
+
'<th onclick="setListSort('status')" class="' + (LIST_SORT === 'status' ? 'sort-active' : '') + '">Status' + arrowFor('status') + '</th>' +
|
|
1968
|
+
'<th onclick="setListSort('requests')" class="' + (LIST_SORT === 'requests' ? 'sort-active' : '') + '">Total Reqs' + arrowFor('requests') + '</th>' +
|
|
1969
|
+
'<th>This Rotation</th>' +
|
|
1970
|
+
'<th onclick="setListSort('quota')" class="' + (LIST_SORT === 'quota' ? 'sort-active' : '') + '">Avg Quota' + arrowFor('quota') + '</th>' +
|
|
1971
|
+
'<th onclick="setListSort('tokens')" class="' + (LIST_SORT === 'tokens' ? 'sort-active' : '') + '">Tokens (in/out)' + arrowFor('tokens') + '</th>' +
|
|
1972
|
+
'<th>Last Used</th>' +
|
|
1973
|
+
'<th>Type</th>' +
|
|
1974
|
+
'</tr></thead><tbody>';
|
|
1975
|
+
|
|
1976
|
+
rows.forEach(function(a) {
|
|
1977
|
+
var avgQuota = a.quota && a.quota.length > 0
|
|
1978
|
+
? Math.round(a.quota.reduce(function(s, q) { return s + q.percentRemaining; }, 0) / a.quota.length)
|
|
1979
|
+
: null;
|
|
1980
|
+
var quotaColor = avgQuota === null ? 'var(--text-dim)' : avgQuota > 50 ? 'var(--green)' : avgQuota > 20 ? 'var(--yellow)' : 'var(--red)';
|
|
1981
|
+
|
|
1982
|
+
var statusColors = {
|
|
1983
|
+
active: 'var(--green)', ready: 'var(--text-dim)', cooldown: 'var(--yellow)',
|
|
1984
|
+
exhausted: 'var(--red)', error: 'var(--orange)', disabled: '#888', flagged: '#ff4444'
|
|
1985
|
+
};
|
|
1986
|
+
var statusColor = statusColors[a.status] || 'var(--text-dim)';
|
|
1987
|
+
|
|
1988
|
+
var ta = tokensByAccount[a.email] || { input: 0, output: 0 };
|
|
1989
|
+
var totalTokens = ta.input + ta.output;
|
|
1990
|
+
|
|
1991
|
+
var lastUsed = a.lastUsed ? formatTime(a.lastUsed) : '--';
|
|
1992
|
+
var tierBadge = a.proDetected
|
|
1993
|
+
? '<span class="badge badge-pro" style="font-size:9px">PRO</span>'
|
|
1994
|
+
: '<span class="badge badge-free" style="font-size:9px">FREE</span>';
|
|
1995
|
+
if (a.familyManager) tierBadge += '<span class="badge badge-fmgr" style="font-size:9px">FMGR</span>';
|
|
1996
|
+
|
|
1997
|
+
var quotaCell = avgQuota === null
|
|
1998
|
+
? '<span style="color:var(--text-dim)">--</span>'
|
|
1999
|
+
: '<div class="list-quota-bar">' +
|
|
2000
|
+
'<div class="list-quota-bar-bg"><div class="list-quota-bar-fill" style="width:' + avgQuota + '%;background:' + quotaColor + '"></div></div>' +
|
|
2001
|
+
'<span style="font-family:JetBrains Mono,monospace;font-size:11px;color:' + quotaColor + '">' + avgQuota + '%</span>' +
|
|
2002
|
+
'</div>';
|
|
2003
|
+
|
|
2004
|
+
var tokensCell = totalTokens > 0
|
|
2005
|
+
? '<span style="font-family:JetBrains Mono,monospace">' + formatTokenCount(ta.input) + '\u00a0/\u00a0' + formatTokenCount(ta.output) + '</span>'
|
|
2006
|
+
: '<span style="color:var(--text-dim)">--</span>';
|
|
2007
|
+
|
|
2008
|
+
html += '<tr class="list-row" onclick="jumpToAccount('' + jsString(a.email) + '')">' +
|
|
2009
|
+
'<td>' +
|
|
2010
|
+
'<div class="list-row-label">' + escapeHtml(maskText(a.label)) + '</div>' +
|
|
2011
|
+
'<div class="list-row-email">' + escapeHtml(maskEmail(a.email)) + '</div>' +
|
|
2012
|
+
'</td>' +
|
|
2013
|
+
'<td><span style="color:' + statusColor + ';font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:0.4px">' + escapeHtml(a.status) + '</span></td>' +
|
|
2014
|
+
'<td style="font-family:JetBrains Mono,monospace;font-weight:700">' + (a.totalRequests || 0) + '</td>' +
|
|
2015
|
+
'<td style="font-family:JetBrains Mono,monospace;color:var(--text-dim)">' + (a.requestsSinceRotation || 0) + '</td>' +
|
|
2016
|
+
'<td>' + quotaCell + '</td>' +
|
|
2017
|
+
'<td>' + tokensCell + '</td>' +
|
|
2018
|
+
'<td style="font-family:JetBrains Mono,monospace;font-size:11px;color:var(--text-dim)">' + lastUsed + '</td>' +
|
|
2019
|
+
'<td>' + tierBadge + '</td>' +
|
|
2020
|
+
'</tr>';
|
|
2021
|
+
});
|
|
2022
|
+
|
|
2023
|
+
html += '</tbody></table>';
|
|
2024
|
+
wrap.innerHTML = html;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
function jumpToAccount(email) {
|
|
2028
|
+
// Switch to grid first
|
|
2029
|
+
switchView('grid');
|
|
2030
|
+
setTimeout(function() {
|
|
2031
|
+
var target = document.querySelector('[data-account-email="' + email.replace(/"/g, '\\"') + '"]');
|
|
2032
|
+
if (target) {
|
|
2033
|
+
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
2034
|
+
target.classList.add('list-highlight');
|
|
2035
|
+
setTimeout(function() { target.classList.remove('list-highlight'); }, 2000);
|
|
2036
|
+
}
|
|
2037
|
+
}, 80);
|
|
2038
|
+
}
|
|
2039
|
+
|
|
1654
2040
|
function renderHealthPill(label, value) {
|
|
1655
2041
|
return '<div class="health-pill"><span class="label">' + escapeHtml(label) + '</span><span class="value">' + escapeHtml(value) + '</span></div>';
|
|
1656
2042
|
}
|
package/src/proxy.ts
CHANGED
|
@@ -412,9 +412,16 @@ export async function withRotation<T>(
|
|
|
412
412
|
|
|
413
413
|
try {
|
|
414
414
|
const jitterMs = rotator.getSafetyJitterMs(account);
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
415
|
+
const globalDelayMs = rotator.getGlobalDelayMs();
|
|
416
|
+
const totalDelayMs = jitterMs + globalDelayMs;
|
|
417
|
+
if (totalDelayMs > 0) {
|
|
418
|
+
if (jitterMs > 0) {
|
|
419
|
+
log(`[${requestId}] Safety slow-mode jitter ${jitterMs}ms for account/project daily budget pressure`, rotator, "warn");
|
|
420
|
+
}
|
|
421
|
+
if (globalDelayMs > 0) {
|
|
422
|
+
log(`[${requestId}] Global request delay ${globalDelayMs}ms applied to slow down requests`, rotator, "info");
|
|
423
|
+
}
|
|
424
|
+
await sleep(totalDelayMs);
|
|
418
425
|
}
|
|
419
426
|
|
|
420
427
|
rotator.recordUpstreamAttempt(account);
|
|
@@ -690,9 +697,16 @@ async function handleProxyRequest(
|
|
|
690
697
|
|
|
691
698
|
try {
|
|
692
699
|
const jitterMs = rotator.getSafetyJitterMs(account);
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
700
|
+
const globalDelayMs = rotator.getGlobalDelayMs();
|
|
701
|
+
const totalDelayMs = jitterMs + globalDelayMs;
|
|
702
|
+
if (totalDelayMs > 0) {
|
|
703
|
+
if (jitterMs > 0) {
|
|
704
|
+
proxyLog(`[${requestId}] Safety slow-mode jitter ${jitterMs}ms for account/project daily budget pressure`, "warn");
|
|
705
|
+
}
|
|
706
|
+
if (globalDelayMs > 0) {
|
|
707
|
+
proxyLog(`[${requestId}] Global request delay ${globalDelayMs}ms applied to slow down requests`, "info");
|
|
708
|
+
}
|
|
709
|
+
await sleep(totalDelayMs);
|
|
696
710
|
}
|
|
697
711
|
rotator.recordUpstreamAttempt(account);
|
|
698
712
|
const forwarded = await forwardRequest(account, { ...body }, flattenHeaders(req.headers));
|
package/src/rotator.ts
CHANGED
|
@@ -1185,6 +1185,11 @@ export class AccountRotator {
|
|
|
1185
1185
|
return Math.floor(min + Math.random() * (max - min + 1));
|
|
1186
1186
|
}
|
|
1187
1187
|
|
|
1188
|
+
getGlobalDelayMs(): number {
|
|
1189
|
+
return this.config.globalRequestDelayMs ?? 0;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
|
|
1188
1193
|
markError(account: AccountRuntime, error: string): void {
|
|
1189
1194
|
account.lastError = error;
|
|
1190
1195
|
account.consecutiveErrors++;
|
package/src/types.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface Config {
|
|
|
29
29
|
maxConcurrentRequestsPerAccount?: number;
|
|
30
30
|
// Hard cap on parallel requests per projectId/model. Conservative default is 1.
|
|
31
31
|
maxConcurrentRequestsPerProjectModel?: number;
|
|
32
|
+
// Global delay in ms added to every request to slow down traffic and avoid rate limits.
|
|
33
|
+
globalRequestDelayMs?: number;
|
|
32
34
|
// Pause projectId/model when several accounts hit provider 429 in a short window. Defaults: 3 hits / 10min / 60min pause.
|
|
33
35
|
projectCircuitBreaker429Threshold?: number;
|
|
34
36
|
projectCircuitBreakerWindowMs?: number;
|
|
@@ -343,6 +343,90 @@ function buildFilterOptions(events, flagEvents) {
|
|
|
343
343
|
};
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
+
|
|
347
|
+
// ── Per-install list ─────────────────────────────────────────────────
|
|
348
|
+
// Returns one summary row per unique installId based on their latest
|
|
349
|
+
// heartbeat/boot event + flag count over the same filtered window.
|
|
350
|
+
function computeInstallList(filters = {}) {
|
|
351
|
+
const { events: allEvents, flagEvents: allFlagEvents } = loadAllEvents();
|
|
352
|
+
|
|
353
|
+
// Apply same filters as computeStats
|
|
354
|
+
const events = allEvents.filter(({ ev, file }) => {
|
|
355
|
+
if (filters.installId && ev.installId !== filters.installId) return false;
|
|
356
|
+
if (filters.version && ev.version !== filters.version) return false;
|
|
357
|
+
if (filters.os && ev.os !== filters.os) return false;
|
|
358
|
+
if (filters.model && !(ev.modelsUsed || []).includes(filters.model)) return false;
|
|
359
|
+
const date = file.replace('.jsonl', '');
|
|
360
|
+
if (filters.from && date < filters.from) return false;
|
|
361
|
+
if (filters.to && date > filters.to) return false;
|
|
362
|
+
return true;
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const flagEvents = allFlagEvents.filter(({ fl, file }) => {
|
|
366
|
+
if (filters.installId && fl.installId !== filters.installId) return false;
|
|
367
|
+
const date = file.replace('-flags.jsonl', '');
|
|
368
|
+
if (filters.from && date < filters.from) return false;
|
|
369
|
+
if (filters.to && date > filters.to) return false;
|
|
370
|
+
return true;
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// Latest heartbeat snapshot per install
|
|
374
|
+
const latest = {}; // installId -> ev
|
|
375
|
+
for (const { ev } of events) {
|
|
376
|
+
const prev = latest[ev.installId];
|
|
377
|
+
if (!prev || ev.ts >= prev.ts) latest[ev.installId] = ev;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// First seen per install
|
|
381
|
+
const firstSeen = {};
|
|
382
|
+
for (const { ev } of events) {
|
|
383
|
+
if (!firstSeen[ev.installId] || ev.ts < firstSeen[ev.installId])
|
|
384
|
+
firstSeen[ev.installId] = ev.ts;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Flag counts per install
|
|
388
|
+
const flagsByInstall = {};
|
|
389
|
+
for (const { fl } of flagEvents) {
|
|
390
|
+
flagsByInstall[fl.installId] = (flagsByInstall[fl.installId] || 0) + 1;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Total requests per install (max across all events — it's cumulative)
|
|
394
|
+
const maxRequests = {};
|
|
395
|
+
for (const { ev } of events) {
|
|
396
|
+
const cur = maxRequests[ev.installId] || 0;
|
|
397
|
+
if ((ev.totalRequests || 0) > cur) maxRequests[ev.installId] = ev.totalRequests || 0;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const list = Object.values(latest).map((ev) => {
|
|
401
|
+
const tokens = ev.tokensByModel && typeof ev.tokensByModel === 'object'
|
|
402
|
+
? ev.tokensByModel : {};
|
|
403
|
+
const savings = calculateSavings(tokens);
|
|
404
|
+
return {
|
|
405
|
+
installId: ev.installId,
|
|
406
|
+
version: ev.version || '?',
|
|
407
|
+
os: ev.os || '?',
|
|
408
|
+
arch: ev.arch || '?',
|
|
409
|
+
accountCount: ev.accountCount || 0,
|
|
410
|
+
totalRequests: maxRequests[ev.installId] || 0,
|
|
411
|
+
routingHealthState: ev.routingHealthState || 'unknown',
|
|
412
|
+
flaggedCount: ev.flaggedCount || 0,
|
|
413
|
+
disabledCount: ev.disabledCount || 0,
|
|
414
|
+
proCount: ev.proCount || 0,
|
|
415
|
+
freeCount: ev.freeCount || 0,
|
|
416
|
+
tokensByModel: tokens,
|
|
417
|
+
savingsUsd: savings.totalUsd,
|
|
418
|
+
flagEvents: flagsByInstall[ev.installId] || 0,
|
|
419
|
+
lastSeen: ev.ts,
|
|
420
|
+
firstSeen: firstSeen[ev.installId] || ev.ts,
|
|
421
|
+
featuresUsed: ev.featuresUsed || {},
|
|
422
|
+
};
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// Sort by totalRequests desc by default
|
|
426
|
+
list.sort((a, b) => b.totalRequests - a.totalRequests);
|
|
427
|
+
return list;
|
|
428
|
+
}
|
|
429
|
+
|
|
346
430
|
function computeStats(filters = {}) {
|
|
347
431
|
const { events: allEvents, flagEvents: allFlagEvents, allFiles } = loadAllEvents();
|
|
348
432
|
|
|
@@ -577,6 +661,37 @@ tr:last-child td{border-bottom:none}
|
|
|
577
661
|
.savings-sub{font-size:12px;color:#718096;margin-bottom:14px}
|
|
578
662
|
.error{background:#2d1515;border:1px solid #742a2a;border-radius:8px;padding:12px;color:#fc8181;margin-bottom:14px}
|
|
579
663
|
.empty{color:#4a5568;font-size:12px;padding:20px;text-align:center}
|
|
664
|
+
|
|
665
|
+
/* ── View toggle ── */
|
|
666
|
+
.view-tabs{display:flex;gap:8px;padding:12px 24px;background:#141820;border-bottom:1px solid #2d3748}
|
|
667
|
+
.view-tab{font-size:12px;font-weight:600;padding:5px 14px;border-radius:999px;border:1px solid #2d3748;background:transparent;color:#718096;cursor:pointer;font-family:inherit;transition:all .2s}
|
|
668
|
+
.view-tab.active{background:rgba(66,153,225,.15);border-color:rgba(66,153,225,.4);color:#63b3ed}
|
|
669
|
+
.view-tab:hover:not(.active){background:rgba(255,255,255,.04);color:#e2e8f0}
|
|
670
|
+
|
|
671
|
+
/* ── Installs list ── */
|
|
672
|
+
.install-toolbar{display:flex;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap}
|
|
673
|
+
.install-search{background:#0f1117;border:1px solid #2d3748;border-radius:6px;padding:6px 12px;color:#e2e8f0;font-size:12px;font-family:inherit;width:200px;outline:none;transition:border-color .2s}
|
|
674
|
+
.install-search:focus{border-color:#4299e1}
|
|
675
|
+
.sort-btn{font-size:11px;padding:4px 10px;border:1px solid #2d3748;background:transparent;color:#718096;border-radius:6px;cursor:pointer;font-family:inherit;font-weight:600;transition:all .2s}
|
|
676
|
+
.sort-btn.active{border-color:rgba(66,153,225,.4);color:#63b3ed;background:rgba(66,153,225,.08)}
|
|
677
|
+
.install-table{width:100%;border-collapse:collapse}
|
|
678
|
+
.install-table th{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:#718096;padding:8px 12px;text-align:left;border-bottom:1px solid #2d3748;background:rgba(255,255,255,.02);white-space:nowrap;cursor:pointer;user-select:none}
|
|
679
|
+
.install-table th:hover{color:#e2e8f0}
|
|
680
|
+
.install-table th .arr{display:inline-block;margin-left:3px;opacity:.35;font-size:9px}
|
|
681
|
+
.install-table th.sort-active .arr{opacity:1;color:#63b3ed}
|
|
682
|
+
.install-table td{padding:9px 12px;font-size:12px;border-bottom:1px solid #1f2535;vertical-align:middle}
|
|
683
|
+
.install-table tr:last-child td{border-bottom:none}
|
|
684
|
+
.install-row{cursor:pointer;transition:background .15s}
|
|
685
|
+
.install-row:hover td{background:rgba(66,153,225,.05)}
|
|
686
|
+
.install-row.selected td{background:rgba(66,153,225,.1)}
|
|
687
|
+
.install-id{font-family:monospace;font-size:11px;color:#718096}
|
|
688
|
+
.install-id strong{color:#63b3ed;display:block;font-size:12px;margin-bottom:1px}
|
|
689
|
+
.mini-bar{display:flex;align-items:center;gap:5px}
|
|
690
|
+
.mini-bar-bg{width:50px;height:4px;background:rgba(255,255,255,.08);border-radius:2px;overflow:hidden;flex-shrink:0}
|
|
691
|
+
.mini-bar-fill{height:100%;border-radius:2px}
|
|
692
|
+
.health-dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:5px;flex-shrink:0}
|
|
693
|
+
.install-list-panel{background:#1a1f2e;border:1px solid #2d3748;border-radius:10px;padding:18px}
|
|
694
|
+
.install-list-panel h2{font-size:11px;font-weight:700;color:#718096;text-transform:uppercase;letter-spacing:.08em;margin-bottom:14px}
|
|
580
695
|
</style>
|
|
581
696
|
</head>
|
|
582
697
|
<body>
|
|
@@ -589,6 +704,7 @@ tr:last-child td{border-bottom:none}
|
|
|
589
704
|
<button onclick="load()">Load Stats</button>
|
|
590
705
|
</div>
|
|
591
706
|
|
|
707
|
+
<div class="view-tabs" id="viewTabs" style="display:none"><button class="view-tab active" id="vtAgg" onclick="switchView('agg')">■ Aggregated</button><button class="view-tab" id="vtList" onclick="switchView('list')">☰ Installations</button></div>
|
|
592
708
|
<div class="filter-bar" id="filterBar" style="display:none">
|
|
593
709
|
<div class="filter-group">
|
|
594
710
|
<label>Install ID</label>
|
|
@@ -707,6 +823,7 @@ async function go(filters){
|
|
|
707
823
|
$('err').style.display='none';
|
|
708
824
|
$('app').style.display='block';
|
|
709
825
|
$('filterBar').style.display='flex';
|
|
826
|
+
$('viewTabs').style.display='flex';
|
|
710
827
|
$('ts').textContent='Updated '+new Date().toLocaleTimeString();
|
|
711
828
|
render(d,filters);
|
|
712
829
|
}catch(e){showErr(e.message);}
|
|
@@ -804,8 +921,134 @@ function render(d, filters={}){
|
|
|
804
921
|
|
|
805
922
|
const saved=localStorage.getItem('st');
|
|
806
923
|
if(saved){_token=saved;$('tok').value=saved;go({});}
|
|
924
|
+
|
|
925
|
+
// ── Installs list view ───────────────────────────────────────────────
|
|
926
|
+
var CURRENT_VIEW = 'agg';
|
|
927
|
+
var INSTALL_SORT = 'requests';
|
|
928
|
+
var INSTALL_SORT_DIR = -1;
|
|
929
|
+
var _installs = [];
|
|
930
|
+
|
|
931
|
+
function switchView(view) {
|
|
932
|
+
CURRENT_VIEW = view;
|
|
933
|
+
$('vtAgg').className = 'view-tab' + (view === 'agg' ? ' active' : '');
|
|
934
|
+
$('vtList').className = 'view-tab' + (view === 'list' ? ' active' : '');
|
|
935
|
+
$('filterBar').style.display = view === 'agg' ? 'flex' : 'none';
|
|
936
|
+
var ae = $('app'); if(ae) ae.style.display = view === 'agg' ? 'block' : 'none';
|
|
937
|
+
var le = $('installsView'); if(le) le.style.display = view === 'list' ? 'block' : 'none';
|
|
938
|
+
if (view === 'list') loadInstalls();
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async function loadInstalls() {
|
|
942
|
+
console.log('[installs] token=', _token ? _token.slice(0,8)+'...' : 'EMPTY');
|
|
943
|
+
if (!_token) { console.log('[installs] abort: no token'); return; }
|
|
944
|
+
try {
|
|
945
|
+
var r = await fetch('/v1/installs', { headers: { 'Authorization': 'Bearer ' + _token } });
|
|
946
|
+
console.log('[installs] status=', r.status);
|
|
947
|
+
if (!r.ok) { showErr('Failed to load installs: ' + r.status); return; }
|
|
948
|
+
_installs = await r.json();
|
|
949
|
+
console.log('[installs] rows=', _installs.length, _installs[0]);
|
|
950
|
+
renderInstallList();
|
|
951
|
+
} catch(e) { console.error('[installs] error:', e); showErr(e.message); }
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function setInstallSort(col) {
|
|
955
|
+
if (INSTALL_SORT === col) { INSTALL_SORT_DIR = -INSTALL_SORT_DIR; }
|
|
956
|
+
else { INSTALL_SORT = col; INSTALL_SORT_DIR = -1; }
|
|
957
|
+
['requests','savings','accounts','flags','lastseen'].forEach(function(c) {
|
|
958
|
+
var b = $('isort-' + c);
|
|
959
|
+
if (b) b.className = 'sort-btn' + (c === INSTALL_SORT ? ' active' : '');
|
|
960
|
+
});
|
|
961
|
+
renderInstallList();
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function renderInstallList() {
|
|
965
|
+
var wrap = $('installTableWrap');
|
|
966
|
+
if (!wrap) return;
|
|
967
|
+
var q = (($('installSearch')||{}).value||'').toLowerCase();
|
|
968
|
+
var rows = _installs.slice().filter(function(r) {
|
|
969
|
+
if (!q) return true;
|
|
970
|
+
return r.installId.toLowerCase().indexOf(q)!==-1 ||
|
|
971
|
+
(r.version||'').toLowerCase().indexOf(q)!==-1 ||
|
|
972
|
+
(r.os||'').toLowerCase().indexOf(q)!==-1;
|
|
973
|
+
});
|
|
974
|
+
rows.sort(function(a,b) {
|
|
975
|
+
var av,bv;
|
|
976
|
+
if (INSTALL_SORT==='requests') {av=a.totalRequests;bv=b.totalRequests;}
|
|
977
|
+
else if (INSTALL_SORT==='savings') {av=a.savingsUsd;bv=b.savingsUsd;}
|
|
978
|
+
else if (INSTALL_SORT==='accounts') {av=a.accountCount;bv=b.accountCount;}
|
|
979
|
+
else if (INSTALL_SORT==='flags') {av=a.flagEvents;bv=b.flagEvents;}
|
|
980
|
+
else if (INSTALL_SORT==='lastseen') {av=a.lastSeen;bv=b.lastSeen;}
|
|
981
|
+
else {av=0;bv=0;}
|
|
982
|
+
if(av<bv) return INSTALL_SORT_DIR;
|
|
983
|
+
if(av>bv) return -INSTALL_SORT_DIR;
|
|
984
|
+
return 0;
|
|
985
|
+
});
|
|
986
|
+
if (rows.length===0) { wrap.innerHTML='<div class="empty">No installs found.</div>'; return; }
|
|
987
|
+
var HC={healthy:'#68d391',cooldown_wait:'#f6e05e',busy:'#63b3ed',paused:'#fc8181',stopped:'#fc8181'};
|
|
988
|
+
function ar(col) {
|
|
989
|
+
if(INSTALL_SORT!==col) return '<span class="arr">↕</span>';
|
|
990
|
+
return '<span class="arr">'+(INSTALL_SORT_DIR===-1?'↓':'↑')+'</span>';
|
|
991
|
+
}
|
|
992
|
+
var html='<table class="install-table"><thead><tr>'+
|
|
993
|
+
'<th>Install ID</th>'+
|
|
994
|
+
'<th onclick="setInstallSort('requests')" class="'+(INSTALL_SORT==='requests'?'sort-active':'')+'">Requests'+ar('requests')+'</th>'+
|
|
995
|
+
'<th onclick="setInstallSort('accounts')" class="'+(INSTALL_SORT==='accounts'?'sort-active':'')+'">Accounts'+ar('accounts')+'</th>'+
|
|
996
|
+
'<th onclick="setInstallSort('savings')" class="'+(INSTALL_SORT==='savings' ?'sort-active':'')+'">Savings' +ar('savings') +'</th>'+
|
|
997
|
+
'<th onclick="setInstallSort('flags')" class="'+(INSTALL_SORT==='flags' ?'sort-active':'')+'">Flags' +ar('flags') +'</th>'+
|
|
998
|
+
'<th>Health</th>'+
|
|
999
|
+
'<th>Version / OS</th>'+
|
|
1000
|
+
'<th onclick="setInstallSort('lastseen')" class="'+(INSTALL_SORT==='lastseen'?'sort-active':'')+'">Last Seen'+ar('lastseen')+'</th>'+
|
|
1001
|
+
'<th></th>'+
|
|
1002
|
+
'</tr></thead><tbody>';
|
|
1003
|
+
rows.forEach(function(r) {
|
|
1004
|
+
var hc=HC[r.routingHealthState]||'#718096';
|
|
1005
|
+
var shortId=r.installId.slice(0,8)+'…';
|
|
1006
|
+
var ls=r.lastSeen?new Date(r.lastSeen).toLocaleString():'—';
|
|
1007
|
+
var fc=r.flagEvents>0?'#fc8181':'#718096';
|
|
1008
|
+
var pf='';
|
|
1009
|
+
if(r.proCount>0||r.freeCount>0)
|
|
1010
|
+
pf='<span style="color:#68d391;font-size:10px">P:'+r.proCount+'</span> <span style="color:#718096;font-size:10px">F:'+r.freeCount+'</span>';
|
|
1011
|
+
html+='<tr class="install-row" onclick="drillDown(''+r.installId+'')">'+
|
|
1012
|
+
'<td><div class="install-id"><strong>'+shortId+'</strong>'+r.installId.slice(8)+'</div></td>'+
|
|
1013
|
+
'<td style="font-family:monospace;font-weight:700">'+fmt(r.totalRequests)+'</td>'+
|
|
1014
|
+
'<td>'+r.accountCount+(pf?'<br>'+pf:'')+'</td>'+
|
|
1015
|
+
'<td style="color:#68d391;font-family:monospace;font-weight:700">'+usd(r.savingsUsd)+'</td>'+
|
|
1016
|
+
'<td style="color:'+fc+';font-weight:700;font-family:monospace">'+r.flagEvents+'</td>'+
|
|
1017
|
+
'<td><span class="health-dot" style="background:'+hc+'"></span><span style="font-size:11px;color:'+hc+'">'+escI(r.routingHealthState||'?')+'</span></td>'+
|
|
1018
|
+
'<td style="font-size:11px"><span style="color:#63b3ed">v'+escI(r.version)+'</span> <span style="color:#718096">'+escI(r.os)+'/'+escI(r.arch)+'</span></td>'+
|
|
1019
|
+
'<td style="font-size:11px;color:#718096;font-family:monospace">'+ls+'</td>'+
|
|
1020
|
+
'<td><button class="sort-btn" style="padding:3px 8px;font-size:10px" onclick="event.stopPropagation();drillDown(''+r.installId+'')">Filter →</button></td>'+
|
|
1021
|
+
'</tr>';
|
|
1022
|
+
});
|
|
1023
|
+
html+='</tbody></table>';
|
|
1024
|
+
wrap.innerHTML=html;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function drillDown(installId) {
|
|
1028
|
+
switchView('agg');
|
|
1029
|
+
var sel=$('fInstall');
|
|
1030
|
+
if(sel) { sel.value=installId; }
|
|
1031
|
+
applyFilters();
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function escI(s){if(!s)return '';return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
|
1035
|
+
|
|
807
1036
|
setInterval(()=>{if(_token){const f={};const i=$('fInstall').value;if(i)f.installId=i;const v=$('fVersion').value;if(v)f.version=v;const o=$('fOS').value;if(o)f.os=o;const m=$('fModel').value;if(m)f.model=m;const fr=$('fFrom').value;if(fr)f.from=fr;const to=$('fTo').value;if(to)f.to=to;go(f);}},60000);
|
|
808
1037
|
</script>
|
|
1038
|
+
<div class="main" id="installsView" style="display:none">
|
|
1039
|
+
<div class="install-list-panel">
|
|
1040
|
+
<h2>✶ Installations</h2>
|
|
1041
|
+
<div class="install-toolbar">
|
|
1042
|
+
<input class="install-search" id="installSearch" placeholder="Search…" oninput="renderInstallList()" />
|
|
1043
|
+
<button class="sort-btn" id="isort-requests" onclick="setInstallSort('requests')">Requests ↕</button>
|
|
1044
|
+
<button class="sort-btn" id="isort-savings" onclick="setInstallSort('savings')">Savings ↕</button>
|
|
1045
|
+
<button class="sort-btn" id="isort-accounts" onclick="setInstallSort('accounts')">Accounts ↕</button>
|
|
1046
|
+
<button class="sort-btn" id="isort-flags" onclick="setInstallSort('flags')">Flags ↕</button>
|
|
1047
|
+
<button class="sort-btn" id="isort-lastseen" onclick="setInstallSort('lastseen')">Last Seen ↕</button>
|
|
1048
|
+
</div>
|
|
1049
|
+
<div id="installTableWrap"></div>
|
|
1050
|
+
</div>
|
|
1051
|
+
</div>
|
|
809
1052
|
</body></html>`;
|
|
810
1053
|
}
|
|
811
1054
|
|
|
@@ -966,7 +1209,7 @@ tr:last-child td{border-bottom:none}
|
|
|
966
1209
|
</div>
|
|
967
1210
|
|
|
968
1211
|
<script>
|
|
969
|
-
|
|
1212
|
+
|
|
970
1213
|
var _notifications = [];
|
|
971
1214
|
|
|
972
1215
|
function $(i) { return document.getElementById(i); }
|
|
@@ -1318,6 +1561,36 @@ const server = createServer(async (req, res) => {
|
|
|
1318
1561
|
return;
|
|
1319
1562
|
}
|
|
1320
1563
|
|
|
1564
|
+
// Installs list (protected)
|
|
1565
|
+
if (method === "GET" && url.startsWith("/v1/installs")) {
|
|
1566
|
+
if (!STATS_TOKEN) {
|
|
1567
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1568
|
+
res.end(JSON.stringify({ error: "STATS_TOKEN not configured" }));
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
const auth = req.headers.authorization || "";
|
|
1572
|
+
if (auth !== `Bearer ${STATS_TOKEN}`) {
|
|
1573
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1574
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
try {
|
|
1578
|
+
const q = parseQueryString(url);
|
|
1579
|
+
const filters = {};
|
|
1580
|
+
if (q.from) filters.from = q.from;
|
|
1581
|
+
if (q.to) filters.to = q.to;
|
|
1582
|
+
if (q.version) filters.version = q.version;
|
|
1583
|
+
if (q.os) filters.os = q.os;
|
|
1584
|
+
const list = computeInstallList(filters);
|
|
1585
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1586
|
+
res.end(JSON.stringify(list));
|
|
1587
|
+
} catch (err) {
|
|
1588
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1589
|
+
res.end(JSON.stringify({ error: "Failed to compute install list" }));
|
|
1590
|
+
}
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1321
1594
|
// Stats (protected)
|
|
1322
1595
|
if (method === "GET" && url.startsWith("/v1/stats")) {
|
|
1323
1596
|
if (!STATS_TOKEN) {
|