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 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.0",
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
- name: t.function.name,
243
- ...(t.function.description ? { description: t.function.description } : {}),
244
- ...(t.function.parameters ? { parameters: sanitizeGeminiSchema(t.function.parameters) as Record<string, unknown> } : {}),
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
- functionCall: { name: tc.function.name, args },
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
- // Current result: use native Gemini functionResponse part
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
- contents.push({ role: "user", parts: [{ functionResponse: { name: fnName, response: responseData } }] });
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(&apos;status&apos;)" class="' + (LIST_SORT === 'status' ? 'sort-active' : '') + '">Status' + arrowFor('status') + '</th>' +
1968
+ '<th onclick="setListSort(&apos;requests&apos;)" class="' + (LIST_SORT === 'requests' ? 'sort-active' : '') + '">Total Reqs' + arrowFor('requests') + '</th>' +
1969
+ '<th>This Rotation</th>' +
1970
+ '<th onclick="setListSort(&apos;quota&apos;)" class="' + (LIST_SORT === 'quota' ? 'sort-active' : '') + '">Avg Quota' + arrowFor('quota') + '</th>' +
1971
+ '<th onclick="setListSort(&apos;tokens&apos;)" 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(&apos;' + jsString(a.email) + '&apos;)">' +
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
- if (jitterMs > 0) {
416
- log(`[${requestId}] Safety slow-mode jitter ${jitterMs}ms for account/project daily budget pressure`, rotator, "warn");
417
- await sleep(jitterMs);
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
- if (jitterMs > 0) {
694
- proxyLog(`[${requestId}] Safety slow-mode jitter ${jitterMs}ms for account/project daily budget pressure`, "warn");
695
- await sleep(jitterMs);
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(&apos;agg&apos;)">■ Aggregated</button><button class="view-tab" id="vtList" onclick="switchView(&apos;list&apos;)">☰ 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">&#8597;</span>';
990
+ return '<span class="arr">'+(INSTALL_SORT_DIR===-1?'&#8595;':'&#8593;')+'</span>';
991
+ }
992
+ var html='<table class="install-table"><thead><tr>'+
993
+ '<th>Install ID</th>'+
994
+ '<th onclick="setInstallSort(&apos;requests&apos;)" class="'+(INSTALL_SORT==='requests'?'sort-active':'')+'">Requests'+ar('requests')+'</th>'+
995
+ '<th onclick="setInstallSort(&apos;accounts&apos;)" class="'+(INSTALL_SORT==='accounts'?'sort-active':'')+'">Accounts'+ar('accounts')+'</th>'+
996
+ '<th onclick="setInstallSort(&apos;savings&apos;)" class="'+(INSTALL_SORT==='savings' ?'sort-active':'')+'">Savings' +ar('savings') +'</th>'+
997
+ '<th onclick="setInstallSort(&apos;flags&apos;)" class="'+(INSTALL_SORT==='flags' ?'sort-active':'')+'">Flags' +ar('flags') +'</th>'+
998
+ '<th>Health</th>'+
999
+ '<th>Version / OS</th>'+
1000
+ '<th onclick="setInstallSort(&apos;lastseen&apos;)" 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(&apos;'+r.installId+'&apos;)">'+
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(&apos;'+r.installId+'&apos;)">Filter &#8594;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
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(&apos;requests&apos;)">Requests ↕</button>
1044
+ <button class="sort-btn" id="isort-savings" onclick="setInstallSort(&apos;savings&apos;)">Savings ↕</button>
1045
+ <button class="sort-btn" id="isort-accounts" onclick="setInstallSort(&apos;accounts&apos;)">Accounts ↕</button>
1046
+ <button class="sort-btn" id="isort-flags" onclick="setInstallSort(&apos;flags&apos;)">Flags ↕</button>
1047
+ <button class="sort-btn" id="isort-lastseen" onclick="setInstallSort(&apos;lastseen&apos;)">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
- var _token = '';
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) {