pi-antigravity-rotator 1.4.0 → 1.4.5

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
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.4.5] - 2026-04-28
6
+
7
+ ### Added
8
+ - **Extended Token Views**: Added `2h`, `4h`, `8h`, and `12h` options to the Token Usage chart. The backend now retains up to 12 hours of minute-level resolution for accurate high-fidelity zooming.
9
+
10
+ ### Changed
11
+ - **Activity Heatmap Scaling**: Expanded the heatmap to cover the last 60 days. The grid is now responsive, taking up the full width of the screen without distorting cell proportions, and the Y-axis now orders hours naturally from 00 to 23.
12
+ - **Timezone Alignment**: X-axis labels on the Token Usage chart and the Heatmap now correctly reflect the browser's local time instead of UTC.
13
+
14
+ ## [1.4.1] - 2026-04-28
15
+
16
+ ### Added
17
+ - **Export Data**: Added `CSV` and `JSON` export buttons to the Token Usage panel on the dashboard to easily download token metrics for external reporting.
18
+
5
19
  ## [1.4.0] - 2026-04-28
6
20
 
7
21
  ### Added
package/README.md CHANGED
@@ -94,15 +94,14 @@ After starting the proxy, open `http://localhost:51200/dashboard` or `http://<yo
94
94
 
95
95
  The dashboard shows:
96
96
 
97
- - **Model Routing table** -- Which account each model (Gemini Pro, Flash, Claude) is currently routed to
98
- - **Account cards** sorted by total quota (highest first), flagged/disabled last:
99
- - Status badge: `active`, `ready`, `cooldown`, `flagged`, `disabled`, or `error`
100
- - Model badges: which models this account is currently serving
101
- - Per-model quota bars with timer type (`idle`/`7d`/`5h`) and reset countdown
102
- - Request counts, last used time, token status
103
- - Fresh-window policy status plus a per-account override button
104
- - Error messages for flagged/errored accounts
105
- - Re-enable button for disabled accounts
97
+ - **Top Status & Controls** -- Real-time routing state, uptime, requests, and PII masking toggle.
98
+ - **Token Usage & Savings** -- Interactive chart (`1h`, `2h`, `4h`, `8h`, `12h`, `1d`, `7d`, `1m`) showing token consumption by model, with estimated USD savings and `CSV`/`JSON` export options.
99
+ - **Activity Heatmap** -- 60-day responsive GitHub-style contribution grid showing request intensity hour by hour.
100
+ - **Latency (p50/p95)** -- Real-time median and 95th percentile tracking for Time-to-First-Byte (TTFB) and Total Duration per model.
101
+ - **Quota Forecast** -- Predictive modeling showing when each model's quota will run out based on the current requests/hour burn rate.
102
+ - **Searchable Request Log** -- Live feed of the last 200 requests with exact timestamps, models, masked accounts, status codes, and latency.
103
+ - **Account Cards** -- Sorted by total quota. Shows status (`active`, `ready`, `cooldown`, `flagged`, `disabled`), served models, quota bars with timers, and precise error messages.
104
+ - **Operator Panels** -- "Attention Needed" summaries for quarantined accounts and a real-time event feed of rotator actions.
106
105
 
107
106
  ![Dashboard](dashboard.png)
108
107
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-antigravity-rotator",
3
- "version": "1.4.0",
3
+ "version": "1.4.5",
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/dashboard.ts CHANGED
@@ -835,11 +835,18 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
835
835
  <div class="routing-panel state-stopped" id="routingHealth"></div>
836
836
 
837
837
  <div class="routing-panel" id="tokenUsagePanel" style="margin-top:12px">
838
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
839
- <strong>Token Usage</strong>
840
- <div style="display:flex;gap:6px;align-items:center">
838
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px">
839
+ <strong style="min-width:max-content">Token Usage</strong>
840
+ <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
841
841
  <div id="tokenTotals" style="font-family:JetBrains Mono,monospace;font-size:0.85rem;color:var(--text-dim);margin-right:12px"></div>
842
+ <button class="btn-secondary btn-sm" onclick="exportData('csv')" title="Export CSV" style="padding:2px 6px">CSV</button>
843
+ <button class="btn-secondary btn-sm" onclick="exportData('json')" title="Export JSON" style="padding:2px 6px;margin-right:8px">JSON</button>
844
+ <div style="width:1px;height:16px;background:var(--border);margin-right:8px"></div>
842
845
  <button class="btn-secondary btn-sm" onclick="setTokenView('1h')" id="tbtn-1h">1h</button>
846
+ <button class="btn-secondary btn-sm" onclick="setTokenView('2h')" id="tbtn-2h">2h</button>
847
+ <button class="btn-secondary btn-sm" onclick="setTokenView('4h')" id="tbtn-4h">4h</button>
848
+ <button class="btn-secondary btn-sm" onclick="setTokenView('8h')" id="tbtn-8h">8h</button>
849
+ <button class="btn-secondary btn-sm" onclick="setTokenView('12h')" id="tbtn-12h">12h</button>
843
850
  <button class="btn-secondary btn-sm" onclick="setTokenView('1d')" id="tbtn-1d">1d</button>
844
851
  <button class="btn-secondary btn-sm" onclick="setTokenView('7d')" id="tbtn-7d">7d</button>
845
852
  <button class="btn-secondary btn-sm" onclick="setTokenView('1m')" id="tbtn-1m">1m</button>
@@ -863,7 +870,7 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
863
870
 
864
871
  <div class="routing-panel" id="heatmapPanel" style="margin-top:12px;display:none">
865
872
  <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
866
- <strong>Activity Heatmap (last 7d)</strong>
873
+ <strong>Activity Heatmap (last 60d)</strong>
867
874
  <span style="color:var(--text-dim);font-size:0.75rem">rows: hour · cols: day</span>
868
875
  </div>
869
876
  <div id="heatmapGrid"></div>
@@ -1200,15 +1207,54 @@ function formatTokenCount(n) {
1200
1207
 
1201
1208
  window.__tokenView = '1h';
1202
1209
 
1210
+ function exportData(format) {
1211
+ if (!window.__lastData || !window.__lastData.tokenUsage) return;
1212
+ var usage = window.__lastData.tokenUsage;
1213
+
1214
+ if (format === 'json') {
1215
+ var dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(usage, null, 2));
1216
+ var a = document.createElement('a');
1217
+ a.href = dataStr;
1218
+ a.download = "rotator-token-usage.json";
1219
+ a.click();
1220
+ } else if (format === 'csv') {
1221
+ var csv = "Tier,Period,Model,InputTokens,OutputTokens,Requests\\n";
1222
+ ['months', 'days', 'hours', 'minutes'].forEach(function(tier) {
1223
+ (usage[tier] || []).forEach(function(b) {
1224
+ if (!b.byModel) return;
1225
+ Object.keys(b.byModel).forEach(function(m) {
1226
+ var d = b.byModel[m];
1227
+ csv += tier + "," + b.period + "," + m + "," + d.inputTokens + "," + d.outputTokens + "," + d.requests + "\\n";
1228
+ });
1229
+ });
1230
+ });
1231
+ var dataStrCSV = "data:text/csv;charset=utf-8," + encodeURIComponent(csv);
1232
+ var a2 = document.createElement('a');
1233
+ a2.href = dataStrCSV;
1234
+ a2.download = "rotator-token-usage.csv";
1235
+ a2.click();
1236
+ }
1237
+ }
1238
+
1203
1239
  function setTokenView(view) {
1204
1240
  window.__tokenView = view;
1205
1241
  refresh();
1206
1242
  }
1207
1243
 
1208
1244
  function formatBucketLabel(period, view) {
1209
- if (view === '1h') return ':' + period.slice(14); // ":05"
1210
- if (view === '1d') return period.slice(11, 13) + 'h'; // "12h"
1211
- if (view === '7d' || view === '1m') return period.slice(5, 10); // "04-28"
1245
+ try {
1246
+ if (view.endsWith('h') && view !== '1d') {
1247
+ var d;
1248
+ if (period.length === 16) d = new Date(period + ':00Z');
1249
+ else d = new Date(period);
1250
+ if (!isNaN(d.getTime())) {
1251
+ if (view === '1h') return ':' + String(d.getMinutes()).padStart(2, '0');
1252
+ return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
1253
+ }
1254
+ }
1255
+ if (view === '1d') return period.slice(11, 13) + 'h';
1256
+ if (view === '7d' || view === '1m') return period.slice(5, 10);
1257
+ } catch(e) {}
1212
1258
  return period;
1213
1259
  }
1214
1260
 
@@ -1220,7 +1266,7 @@ function renderTokenChart(tokenUsage) {
1220
1266
  var view = window.__tokenView || '1h';
1221
1267
 
1222
1268
  // Highlight active button
1223
- ['1h', '1d', '7d', '1m'].forEach(function(v) {
1269
+ ['1h', '2h', '4h', '8h', '12h', '1d', '7d', '1m'].forEach(function(v) {
1224
1270
  var btn = document.getElementById('tbtn-' + v);
1225
1271
  if (btn) btn.className = 'btn-secondary btn-sm' + (v === view ? ' active' : '');
1226
1272
  });
@@ -1230,11 +1276,12 @@ function renderTokenChart(tokenUsage) {
1230
1276
  return;
1231
1277
  }
1232
1278
 
1233
- // Helper: merge buckets into a map by truncated period key
1234
- function mergeBucketsBy(sources, sliceLen, limit) {
1279
+ // Helper: merge buckets into a map by a grouping key
1280
+ function mergeBucketsBy(sources, keyFn, limit) {
1235
1281
  var map = {};
1236
1282
  sources.forEach(function(b) {
1237
- var key = b.period.slice(0, sliceLen);
1283
+ var key = keyFn(b.period);
1284
+ if (!key) return;
1238
1285
  if (!map[key]) map[key] = { period: key, inputTokens: 0, outputTokens: 0, requests: 0, byModel: {} };
1239
1286
  map[key].inputTokens += b.inputTokens;
1240
1287
  map[key].outputTokens += b.outputTokens;
@@ -1249,19 +1296,50 @@ function renderTokenChart(tokenUsage) {
1249
1296
  return Object.keys(map).sort().map(function(k) { return map[k]; }).slice(-limit);
1250
1297
  }
1251
1298
 
1299
+ function getLocalKey(periodStr, type) {
1300
+ try {
1301
+ var d;
1302
+ if (periodStr.length === 10) d = new Date(periodStr + 'T00:00:00Z');
1303
+ else if (periodStr.length === 13) d = new Date(periodStr + ':00:00Z');
1304
+ else if (periodStr.length === 16) d = new Date(periodStr + ':00Z');
1305
+ else d = new Date(periodStr);
1306
+ if (isNaN(d.getTime())) return periodStr;
1307
+
1308
+ var y = d.getFullYear();
1309
+ var mo = String(d.getMonth() + 1).padStart(2, '0');
1310
+ var da = String(d.getDate()).padStart(2, '0');
1311
+ var h = String(d.getHours()).padStart(2, '0');
1312
+ var mi = d.getMinutes();
1313
+
1314
+ if (type === 'day') return y + '-' + mo + '-' + da;
1315
+ if (type === 'hour') return y + '-' + mo + '-' + da + 'T' + h;
1316
+ if (type === '5min') return y + '-' + mo + '-' + da + 'T' + h + ':' + String(Math.floor(mi/5)*5).padStart(2, '0');
1317
+ if (type === '4min') return y + '-' + mo + '-' + da + 'T' + h + ':' + String(Math.floor(mi/4)*4).padStart(2, '0');
1318
+ if (type === '2min') return y + '-' + mo + '-' + da + 'T' + h + ':' + String(Math.floor(mi/2)*2).padStart(2, '0');
1319
+ } catch(e) {}
1320
+ return periodStr;
1321
+ }
1322
+
1252
1323
  var allTiers = (tokenUsage.months || []).concat(tokenUsage.days || []).concat(tokenUsage.hours || []).concat(tokenUsage.minutes || []);
1253
1324
 
1254
1325
  // Pick tier based on view
1255
1326
  var buckets;
1256
1327
  if (view === '1h') {
1257
1328
  buckets = (tokenUsage.minutes || []).slice(-60);
1329
+ } else if (view === '2h') {
1330
+ buckets = (tokenUsage.minutes || []).slice(-120);
1331
+ } else if (view === '4h') {
1332
+ buckets = mergeBucketsBy((tokenUsage.minutes || []), function(p) { return getLocalKey(p, '2min'); }, 120);
1333
+ } else if (view === '8h') {
1334
+ buckets = mergeBucketsBy((tokenUsage.minutes || []), function(p) { return getLocalKey(p, '4min'); }, 120);
1335
+ } else if (view === '12h') {
1336
+ buckets = mergeBucketsBy((tokenUsage.minutes || []), function(p) { return getLocalKey(p, '5min'); }, 144);
1258
1337
  } else if (view === '1d') {
1259
- // Merge hours + minutes into hourly buckets, last 24h
1260
- buckets = mergeBucketsBy((tokenUsage.hours || []).concat(tokenUsage.minutes || []), 13, 24);
1338
+ buckets = mergeBucketsBy((tokenUsage.hours || []).concat(tokenUsage.minutes || []), function(p) { return getLocalKey(p, 'hour'); }, 24);
1261
1339
  } else if (view === '7d') {
1262
- buckets = mergeBucketsBy(allTiers, 10, 7);
1340
+ buckets = mergeBucketsBy(allTiers, function(p) { return getLocalKey(p, 'day'); }, 7);
1263
1341
  } else {
1264
- buckets = mergeBucketsBy(allTiers, 10, 30);
1342
+ buckets = mergeBucketsBy(allTiers, function(p) { return getLocalKey(p, 'day'); }, 30);
1265
1343
  }
1266
1344
 
1267
1345
  if (!buckets || buckets.length === 0) {
@@ -1287,14 +1365,17 @@ function renderTokenChart(tokenUsage) {
1287
1365
  });
1288
1366
  if (maxTokens === 0) maxTokens = 1;
1289
1367
 
1290
- var barWidth = Math.max(16, Math.min(36, Math.floor(600 / buckets.length) - 4));
1291
- var gap = 4;
1368
+ var chartWidth = chart.clientWidth || 800;
1369
+ var minSvgWidth = buckets.length * 16 + 40;
1370
+ var svgWidth = Math.max(chartWidth, minSvgWidth);
1371
+ var availableWidth = svgWidth - 50;
1372
+ var step = availableWidth / Math.max(1, buckets.length);
1373
+ var barWidth = Math.min(36, step * 0.8);
1292
1374
  var chartHeight = 140;
1293
- var svgWidth = buckets.length * (barWidth + gap) + 60;
1294
1375
 
1295
1376
  var bars = '';
1296
1377
  buckets.forEach(function(b, i) {
1297
- var x = 40 + i * (barWidth + gap);
1378
+ var x = 40 + i * step + (step - barWidth) / 2;
1298
1379
 
1299
1380
  // Stack by model
1300
1381
  var yOffset = chartHeight;
@@ -1365,12 +1446,14 @@ function renderHeatmap(tokenUsage) {
1365
1446
  var hours = tokenUsage.hours || [];
1366
1447
  var minutes = tokenUsage.minutes || [];
1367
1448
  var now = new Date();
1449
+ var daysCount = 60;
1368
1450
  var days = [];
1369
- for (var i = 6; i >= 0; i--) {
1451
+ for (var i = daysCount - 1; i >= 0; i--) {
1370
1452
  var d = new Date(now);
1371
1453
  d.setDate(now.getDate() - i);
1372
- var key = d.toISOString().slice(0, 10);
1373
- days.push({ key: key, label: key.slice(5) });
1454
+ var key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
1455
+ // show label only for every 7th day to avoid crowding
1456
+ days.push({ key: key, label: (i % 7 === 0) ? key.slice(5) : '' });
1374
1457
  }
1375
1458
 
1376
1459
  var cellMap = {}; // day|hour -> requests
@@ -1380,18 +1463,30 @@ function renderHeatmap(tokenUsage) {
1380
1463
  cellMap[k] += reqs || 0;
1381
1464
  }
1382
1465
 
1466
+ function parseLocal(periodStr) {
1467
+ var d;
1468
+ if (periodStr.length === 10) d = new Date(periodStr + 'T00:00:00Z');
1469
+ else if (periodStr.length === 13) d = new Date(periodStr + ':00:00Z');
1470
+ else if (periodStr.length === 16) d = new Date(periodStr + ':00Z');
1471
+ else d = new Date(periodStr);
1472
+
1473
+ if (isNaN(d.getTime())) return null;
1474
+ return {
1475
+ dayKey: d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'),
1476
+ hour: d.getHours()
1477
+ };
1478
+ }
1479
+
1383
1480
  hours.forEach(function(b) {
1384
- if (!b.period || b.period.length < 13) return;
1385
- var dayKey = b.period.slice(0, 10);
1386
- var hour = Number(b.period.slice(11, 13));
1387
- addBucket(dayKey, hour, b.requests);
1481
+ if (!b.period) return;
1482
+ var loc = parseLocal(b.period);
1483
+ if (loc) addBucket(loc.dayKey, loc.hour, b.requests);
1388
1484
  });
1389
1485
 
1390
1486
  minutes.forEach(function(b) {
1391
- if (!b.period || b.period.length < 16) return;
1392
- var dayKey = b.period.slice(0, 10);
1393
- var hour = Number(b.period.slice(11, 13));
1394
- addBucket(dayKey, hour, b.requests);
1487
+ if (!b.period) return;
1488
+ var loc = parseLocal(b.period);
1489
+ if (loc) addBucket(loc.dayKey, loc.hour, b.requests);
1395
1490
  });
1396
1491
 
1397
1492
  var max = 0;
@@ -1412,17 +1507,17 @@ function renderHeatmap(tokenUsage) {
1412
1507
  return 'rgba(56,189,248,0.92)';
1413
1508
  }
1414
1509
 
1415
- var html = '<div style="overflow-x:auto"><table style="border-collapse:separate;border-spacing:3px;font-family:JetBrains Mono,monospace;font-size:0.7rem">';
1416
- html += '<tr><th style="color:var(--text-dim);padding-right:6px">h</th>';
1417
- days.forEach(function(d) { html += '<th style="color:var(--text-dim);font-weight:500">' + d.label + '</th>'; });
1510
+ var html = '<div style="overflow-x:auto"><table style="width:100%;min-width:800px;border-collapse:separate;border-spacing:2px;table-layout:fixed;font-family:JetBrains Mono,monospace;font-size:0.6rem">';
1511
+ html += '<tr><th style="color:var(--text-dim);padding-right:6px;width:20px">h</th>';
1512
+ days.forEach(function(d) { html += '<th style="color:var(--text-dim);font-weight:500;text-align:left;white-space:nowrap;overflow:visible">' + d.label + '</th>'; });
1418
1513
  html += '</tr>';
1419
1514
 
1420
- for (var hour = 23; hour >= 0; hour--) {
1421
- html += '<tr><td style="color:var(--text-dim);padding-right:6px">' + String(hour).padStart(2, '0') + '</td>';
1515
+ for (var hour = 0; hour < 24; hour++) {
1516
+ html += '<tr><td style="color:var(--text-dim);padding-right:6px;text-align:right">' + String(hour).padStart(2, '0') + '</td>';
1422
1517
  for (var j = 0; j < days.length; j++) {
1423
1518
  var day = days[j].key;
1424
1519
  var val = cellMap[day + '|' + hour] || 0;
1425
- html += '<td title="' + day + ' ' + String(hour).padStart(2, '0') + ':00 · ' + val + ' req" style="width:13px;height:13px;border-radius:3px;background:' + colorFor(val) + ';border:1px solid rgba(255,255,255,0.08)"></td>';
1520
+ html += '<td title="' + day + ' ' + String(hour).padStart(2, '0') + ':00 · ' + val + ' req" style="height:14px;border-radius:2px;background:' + colorFor(val) + ';border:1px solid rgba(255,255,255,0.05)"></td>';
1426
1521
  }
1427
1522
  html += '</tr>';
1428
1523
  }
@@ -1637,6 +1732,22 @@ function renderRequestLog(log) {
1637
1732
  });
1638
1733
  })();
1639
1734
 
1735
+ function maskEventMessage(msg) {
1736
+ if (!MASK_MODE) return escapeHtml(msg);
1737
+ var out = msg;
1738
+ if (window.__lastData && window.__lastData.accounts) {
1739
+ window.__lastData.accounts.forEach(function(a) {
1740
+ if (a.label && out.indexOf(a.label) !== -1) {
1741
+ out = out.split(a.label).join('***');
1742
+ }
1743
+ if (a.email && out.indexOf(a.email) !== -1) {
1744
+ out = out.split(a.email).join('***');
1745
+ }
1746
+ });
1747
+ }
1748
+ return escapeHtml(out);
1749
+ }
1750
+
1640
1751
  function renderRecentEvents(events) {
1641
1752
  var panel = document.getElementById('recentEventsPanel');
1642
1753
  var allEvents = events || [];
@@ -1658,7 +1769,7 @@ function renderRecentEvents(events) {
1658
1769
  return '<div class="event-item level-' + (event.level || 'info') + '">' +
1659
1770
  '<div class="event-time">' + formatTime(event.timestamp) + '</div>' +
1660
1771
  '<div class="event-source ' + event.source + '">' + escapeHtml(event.source) + '</div>' +
1661
- '<div class="event-message">' + escapeHtml(event.message) + '</div>' +
1772
+ '<div class="event-message">' + maskEventMessage(event.message) + '</div>' +
1662
1773
  '</div>';
1663
1774
  }).join('');
1664
1775
 
package/src/rotator.ts CHANGED
@@ -719,8 +719,8 @@ export class AccountRotator {
719
719
 
720
720
  private consolidateTokenBuckets(now: Date): void {
721
721
  const nowMs = now.getTime();
722
- const KEEP_MINUTES_MS = 120 * 60 * 1000; // keep 2h of minutes
723
- const KEEP_HOURS_MS = 48 * 3600 * 1000; // keep 48h of hours
722
+ const KEEP_MINUTES_MS = 12 * 3600 * 1000; // keep 12h of minutes
723
+ const KEEP_HOURS_MS = 60 * 86400 * 1000; // keep 60d of hours
724
724
  const KEEP_DAYS_MS = 60 * 86400 * 1000; // keep 60d of days
725
725
 
726
726
  // Helper: parse period string to epoch ms (approximate, enough for cutoff)