pi-antigravity-rotator 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,8 @@ Multi-account rotation proxy for Google Antigravity. Distributes API usage acros
10
10
  - **Smart rotation** -- Rotates only the specific model whose quota dropped, leaving other models on their current accounts
11
11
  - **Infringement detection** -- On 403 with infringement/abuse/suspension keywords, the account is immediately flagged and excluded from routing
12
12
  - **Automatic failover** -- On 429 rate limits, instantly switches the affected model to the next available account
13
+ - **Concurrency guardrails** -- Limits each account to one in-flight request by default to avoid bursty pressure
14
+ - **Protective pause** -- Pauses all routing for several hours after serious ToS/abuse-style flags so the rest of the pool is not burned
13
15
  - **Token auto-refresh** -- Tokens are refreshed automatically before expiry; no manual management
14
16
  - **Endpoint cascade** -- Tries daily, autopush, and prod API endpoints for resilience
15
17
  - **Web dashboard** -- Real-time view of model routing table, per-account quota bars with per-model timers, and flagged account alerts
@@ -73,7 +75,7 @@ The dashboard shows:
73
75
  - Per-model quota bars with timer type (`fresh`/`7d`/`5h`) and reset countdown
74
76
  - Request counts, last used time, token status
75
77
  - Error messages for flagged/errored accounts
76
- - Re-enable button for flagged or disabled accounts
78
+ - Re-enable button for disabled accounts
77
79
 
78
80
  ![Dashboard](dashboard.png)
79
81
 
@@ -127,21 +129,33 @@ The proxy detects blocked/suspended accounts at three levels:
127
129
 
128
130
  3. **API 403** (on request) -- If the response body contains infringement keywords (`infring`, `suspend`, `abus`, `terminat`, `violat`, `banned`, `policy`, `forbidden`), the account is flagged.
129
131
 
130
- Flagged accounts are **immediately excluded** from all model routing. The dashboard shows a red `FLAGGED` badge with the error message. Use the Re-enable button or `POST /api/enable/<email>` to clear the flag.
132
+ Flagged accounts are **immediately excluded** from all model routing. The dashboard shows a red `FLAGGED` badge with the error message and quarantine guidance. Flagged accounts are intentionally kept out of rotation until the provider explicitly restores access.
131
133
 
132
134
  ### Cooldown Management
133
135
 
134
136
  - Cooldowns are capped at **30 minutes** max
135
137
  - Stale cooldowns from previous sessions are capped on startup
136
- - Use `POST /api/reset-cooldowns` to clear all cooldowns at once
138
+ - The dashboard shows why routing is waiting, how long until the next retry window, and which accounts are cooling down
137
139
  - Quota-based rotation only triggers if a healthy account is available; the proxy won't rotate away from a working account if there's no better alternative
138
140
 
139
141
  ### Error Handling
140
142
 
141
143
  - **429** (rate limit) -- account is marked exhausted with cooldown, rotates to next
142
- - **503** (no capacity) -- returned directly to the agent for its own retry/backoff
144
+ - **503** (no capacity) -- returned directly to the agent when all healthy accounts are cooling down, busy, flagged, or disabled
143
145
  - **5xx** (other server errors) -- account error counter incremented, rotates to next
144
146
 
147
+ ### Dashboard Visibility
148
+
149
+ The dashboard is intended to replace day-to-day `journalctl` digging for normal operations. The top status panel shows:
150
+
151
+ - The current routing state (`healthy`, `cooldown_wait`, `busy`, `paused`, `stopped`)
152
+ - The exact stop or wait reason
153
+ - The next retry window when cooldowns are active
154
+ - Protective pause remaining time and the provider signal that triggered it
155
+ - Pool counts for available, ready, active, cooldown, busy, flagged, disabled, and error accounts
156
+ - An `Attention Needed` section summarizing flagged, cooling, disabled, and error accounts
157
+ - A recent event feed with the latest rotator/proxy incidents that led to the current state
158
+
145
159
  ## Configuration
146
160
 
147
161
  Config files (`accounts.json`, `state.json`) are stored in `~/.pi-antigravity-rotator/` by default. Override with:
@@ -197,8 +211,7 @@ pi-antigravity-rotator start --config-dir /path/to/config
197
211
  |--------|------|-------------|
198
212
  | `GET` | `/dashboard` | Web dashboard |
199
213
  | `GET` | `/api/status` | JSON status: accounts, quotas, model routing, flags |
200
- | `POST` | `/api/enable/<email>` | Clear flagged/disabled state and re-enable an account |
201
- | `POST` | `/api/reset-cooldowns` | Clear all cooldowns on all accounts |
214
+ | `POST` | `/api/enable/<email>` | Re-enable a disabled account after its underlying issue is fixed |
202
215
  | `POST` | `/v1internal:streamGenerateContent` | Proxy endpoint (used by pi) |
203
216
 
204
217
  ## Running as a Service
@@ -235,7 +248,7 @@ Check the error message. Common causes: revoked OAuth consent, expired refresh t
235
248
  Quota data appears after the first poll cycle (up to 5 minutes). Ensure accounts have valid tokens.
236
249
 
237
250
  **All accounts exhausted**
238
- The proxy uses the account with the shortest remaining cooldown. Add more accounts or increase `requestsPerRotation`.
251
+ The proxy now returns `503` and waits for cooldown or manual recovery. It does not reuse cooling-down accounts.
239
252
 
240
253
  **Multiple agents on different models**
241
254
  This is fully supported. Each model routes independently. Agent 1 using Gemini Pro and Agent 2 using Claude will each have their own active account and won't interfere with each other's rotation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-antigravity-rotator",
3
- "version": "1.3.0",
3
+ "version": "1.3.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/dashboard.ts CHANGED
@@ -18,16 +18,10 @@ export function serveStatusApi(res: ServerResponse, rotator: AccountRotator): vo
18
18
 
19
19
  export function serveEnableApi(res: ServerResponse, rotator: AccountRotator, email: string): void {
20
20
  const ok = rotator.enableAccount(email);
21
- res.writeHead(ok ? 200 : 404, { "Content-Type": "application/json" });
21
+ res.writeHead(ok ? 200 : 409, { "Content-Type": "application/json" });
22
22
  res.end(JSON.stringify({ ok, email }));
23
23
  }
24
24
 
25
- export function serveResetCooldownsApi(res: ServerResponse, rotator: AccountRotator): void {
26
- const count = rotator.resetAllCooldowns();
27
- res.writeHead(200, { "Content-Type": "application/json" });
28
- res.end(JSON.stringify({ ok: true, resetCount: count }));
29
- }
30
-
31
25
  const DASHBOARD_HTML = `<!DOCTYPE html>
32
26
  <html lang="en">
33
27
  <head>
@@ -442,6 +436,203 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
442
436
  .advisor-action-label { font-weight: 500; }
443
437
  .advisor-action-reason { color: var(--text-dim); font-size: 11px; margin-left: auto; }
444
438
  .advisor-empty { color: var(--text-dim); font-size: 12px; font-style: italic; }
439
+ .routing-panel {
440
+ border-radius: var(--radius);
441
+ padding: 14px 16px;
442
+ margin-bottom: 24px;
443
+ }
444
+ .routing-panel strong { display: block; margin-bottom: 4px; }
445
+ .routing-panel.state-healthy {
446
+ background: rgba(52, 211, 153, 0.08);
447
+ border: 1px solid rgba(52, 211, 153, 0.24);
448
+ border-left: 4px solid var(--green);
449
+ }
450
+ .routing-panel.state-healthy strong { color: var(--green); }
451
+ .routing-panel.state-cooldown_wait {
452
+ background: rgba(251, 191, 36, 0.08);
453
+ border: 1px solid rgba(251, 191, 36, 0.24);
454
+ border-left: 4px solid var(--yellow);
455
+ }
456
+ .routing-panel.state-cooldown_wait strong { color: var(--yellow); }
457
+ .routing-panel.state-busy {
458
+ background: rgba(96, 165, 250, 0.08);
459
+ border: 1px solid rgba(96, 165, 250, 0.24);
460
+ border-left: 4px solid var(--blue);
461
+ }
462
+ .routing-panel.state-busy strong { color: var(--blue); }
463
+ .routing-panel.state-paused,
464
+ .routing-panel.state-stopped {
465
+ background: rgba(248, 113, 113, 0.08);
466
+ border: 1px solid rgba(248, 113, 113, 0.25);
467
+ border-left: 4px solid var(--red);
468
+ }
469
+ .routing-panel.state-paused strong,
470
+ .routing-panel.state-stopped strong { color: var(--red); }
471
+ .ops-buttons { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
472
+ .ops-warning { margin-top:10px; font-size:11px; color: var(--text-dim); line-height:1.45; }
473
+ .btn-secondary {
474
+ font-size: 11px;
475
+ padding: 4px 12px;
476
+ border: 1px solid var(--border);
477
+ background: transparent;
478
+ color: var(--text);
479
+ border-radius: 6px;
480
+ cursor: pointer;
481
+ font-family: var(--font);
482
+ font-weight: 500;
483
+ }
484
+ .health-grid {
485
+ display:grid;
486
+ grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
487
+ gap: 8px;
488
+ margin-top: 12px;
489
+ }
490
+ .health-pill {
491
+ background: rgba(255,255,255,0.04);
492
+ border: 1px solid var(--border);
493
+ border-radius: 8px;
494
+ padding: 8px 10px;
495
+ }
496
+ .health-pill .label {
497
+ font-size: 10px;
498
+ text-transform: uppercase;
499
+ letter-spacing: 0.5px;
500
+ color: var(--text-dim);
501
+ }
502
+ .health-pill .value {
503
+ font-size: 15px;
504
+ font-family: 'JetBrains Mono', monospace;
505
+ margin-top: 4px;
506
+ }
507
+ .operator-panel {
508
+ background: var(--surface);
509
+ border: 1px solid var(--border);
510
+ border-radius: var(--radius);
511
+ padding: 16px 18px;
512
+ margin-bottom: 24px;
513
+ }
514
+ .operator-title {
515
+ font-size: 11px;
516
+ text-transform: uppercase;
517
+ letter-spacing: 0.8px;
518
+ color: var(--text-dim);
519
+ margin-bottom: 10px;
520
+ }
521
+ .operator-list {
522
+ display: grid;
523
+ gap: 8px;
524
+ }
525
+ .operator-item {
526
+ display: flex;
527
+ align-items: flex-start;
528
+ justify-content: space-between;
529
+ gap: 12px;
530
+ padding: 10px 12px;
531
+ border-radius: 8px;
532
+ background: rgba(255,255,255,0.03);
533
+ border: 1px solid var(--border);
534
+ }
535
+ .operator-item strong {
536
+ display: block;
537
+ font-size: 12px;
538
+ margin-bottom: 2px;
539
+ }
540
+ .operator-item span {
541
+ display: block;
542
+ font-size: 11px;
543
+ color: var(--text-dim);
544
+ line-height: 1.45;
545
+ }
546
+ .operator-meta {
547
+ font-family: 'JetBrains Mono', monospace;
548
+ font-size: 11px;
549
+ color: var(--text);
550
+ white-space: nowrap;
551
+ flex-shrink: 0;
552
+ }
553
+ .events-panel {
554
+ background: var(--surface);
555
+ border: 1px solid var(--border);
556
+ border-radius: var(--radius);
557
+ padding: 16px 18px;
558
+ margin-bottom: 24px;
559
+ }
560
+ .events-list {
561
+ display: grid;
562
+ gap: 8px;
563
+ }
564
+ .events-toolbar {
565
+ display: flex;
566
+ gap: 8px;
567
+ flex-wrap: wrap;
568
+ margin-bottom: 12px;
569
+ }
570
+ .event-filter {
571
+ font-size: 11px;
572
+ padding: 5px 10px;
573
+ border: 1px solid var(--border);
574
+ background: transparent;
575
+ color: var(--text-dim);
576
+ border-radius: 999px;
577
+ cursor: pointer;
578
+ font-family: var(--font);
579
+ font-weight: 600;
580
+ }
581
+ .event-filter.active {
582
+ background: rgba(124, 92, 252, 0.14);
583
+ border-color: rgba(124, 92, 252, 0.28);
584
+ color: var(--text);
585
+ }
586
+ .event-item {
587
+ display: grid;
588
+ grid-template-columns: 90px 56px 1fr;
589
+ gap: 10px;
590
+ align-items: start;
591
+ padding: 10px 12px;
592
+ border-radius: 8px;
593
+ background: rgba(255,255,255,0.03);
594
+ border: 1px solid var(--border);
595
+ }
596
+ .event-item.level-warn {
597
+ border-color: rgba(251, 191, 36, 0.22);
598
+ }
599
+ .event-item.level-error {
600
+ border-color: rgba(248, 113, 113, 0.22);
601
+ }
602
+ .event-time {
603
+ font-family: 'JetBrains Mono', monospace;
604
+ font-size: 11px;
605
+ color: var(--text-dim);
606
+ white-space: nowrap;
607
+ }
608
+ .event-source {
609
+ font-size: 10px;
610
+ font-weight: 700;
611
+ text-transform: uppercase;
612
+ letter-spacing: 0.5px;
613
+ padding: 2px 6px;
614
+ border-radius: 999px;
615
+ text-align: center;
616
+ }
617
+ .event-source.rotator {
618
+ background: rgba(124, 92, 252, 0.14);
619
+ color: var(--accent);
620
+ }
621
+ .event-source.proxy {
622
+ background: rgba(96, 165, 250, 0.14);
623
+ color: var(--blue);
624
+ }
625
+ .event-message {
626
+ font-size: 12px;
627
+ line-height: 1.45;
628
+ color: var(--text);
629
+ word-break: break-word;
630
+ }
631
+ .events-empty {
632
+ font-size: 12px;
633
+ color: var(--text-dim);
634
+ padding: 10px 2px 2px;
635
+ }
445
636
  </style>
446
637
  </head>
447
638
  <body>
@@ -452,6 +643,7 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
452
643
  Uptime: <span id="uptime">--</span> |
453
644
  Port: <span id="port">--</span> |
454
645
  Rotation: <span id="rotation">--</span> reqs |
646
+ Updated: <span id="lastRefresh">--</span> |
455
647
  <button id="maskBtn" onclick="toggleMask()" style="background:none;border:1px solid var(--border);color:var(--text-dim);padding:2px 8px;border-radius:4px;cursor:pointer;font-size:12px;font-family:inherit;">PII: Visible</button>
456
648
  </div>
457
649
  </div>
@@ -471,6 +663,12 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
471
663
  </div>
472
664
  </div>
473
665
 
666
+ <div class="routing-panel state-stopped" id="routingHealth"></div>
667
+
668
+ <div class="operator-panel" id="attentionPanel" style="display:none"></div>
669
+
670
+ <div class="events-panel" id="recentEventsPanel" style="display:none"></div>
671
+
474
672
  <div class="model-routing" id="modelRouting"></div>
475
673
 
476
674
  <div class="advisor-panel" id="proAdvisor" style="display:none"></div>
@@ -544,12 +742,53 @@ function renderAccounts(data) {
544
742
  document.getElementById('uptime').textContent = formatDuration(data.uptime);
545
743
  document.getElementById('port').textContent = data.proxyPort;
546
744
  document.getElementById('rotation').textContent = data.requestsPerRotation;
745
+ document.getElementById('lastRefresh').textContent = new Date(now).toLocaleTimeString();
547
746
  document.getElementById('totalRequests').textContent = data.totalRequestsAllAccounts;
548
747
  document.getElementById('accountCounts').textContent = data.accounts.length;
549
748
  document.getElementById('healthyCount').textContent =
550
749
  data.accounts.filter(function(a) { return a.status === 'active' || a.status === 'ready'; }).length;
551
750
 
751
+ var routingHealth = document.getElementById('routingHealth');
752
+ var health = data.routingHealth || {};
753
+ var state = health.state || 'stopped';
754
+ var stateColor = {
755
+ healthy: 'var(--green)',
756
+ paused: 'var(--red)',
757
+ cooldown_wait: 'var(--yellow)',
758
+ busy: 'var(--blue)',
759
+ stopped: 'var(--red)'
760
+ }[state];
761
+ routingHealth.className = 'routing-panel state-' + state;
762
+ var nextRetry = health.nextRetryIn > 0 ? '<div style="margin-top:6px;">Next retry window: <span style="font-family:JetBrains Mono, monospace;">' + formatDuration(health.nextRetryIn) + '</span></div>' : '';
763
+ var pauseWindow = data.protectivePauseRemaining > 0
764
+ ? '<div style="margin-top:6px;">Protective pause: <span style="font-family:JetBrains Mono, monospace;">' + formatDuration(data.protectivePauseRemaining) + '</span> remaining</div>'
765
+ : '';
766
+ var healthGrid =
767
+ '<div class="health-grid">' +
768
+ renderHealthPill('Available', health.availableCount || 0) +
769
+ renderHealthPill('Active', health.activeCount || 0) +
770
+ renderHealthPill('Ready', health.readyCount || 0) +
771
+ renderHealthPill('Cooldown', health.cooldownCount || 0) +
772
+ renderHealthPill('Busy', health.busyCount || 0) +
773
+ renderHealthPill('Flagged', health.flaggedCount || 0) +
774
+ renderHealthPill('Disabled', health.disabledCount || 0) +
775
+ renderHealthPill('Error', health.errorCount || 0) +
776
+ '</div>';
777
+ routingHealth.innerHTML =
778
+ '<strong style="color:' + stateColor + '">Routing: ' + String(health.state || 'unknown').replace(/_/g, ' ') + '</strong>' +
779
+ '<div>' + (health.reason || 'No routing health information available') + '</div>' +
780
+ nextRetry +
781
+ pauseWindow +
782
+ (data.protectivePauseReason && data.protectivePauseRemaining > 0 ? '<div style="margin-top:6px;color:var(--text-dim);font-family:JetBrains Mono, monospace;">' + data.protectivePauseReason.slice(0, 220) + '</div>' : '') +
783
+ healthGrid +
784
+ '<div class="ops-buttons">' +
785
+ '<button class="btn-secondary" onclick="refresh()">Refresh</button>' +
786
+ '</div>' +
787
+ '<div class="ops-warning">When routing stops, that is intentional. The dashboard now surfaces the stop reason, retry window, protective pause, and blocker counts here so the operator does not need to rely on system logs.</div>';
788
+
552
789
  renderModelRouting(data.activeAccounts);
790
+ renderAttentionPanel(data);
791
+ renderRecentEvents(data.recentEvents);
553
792
 
554
793
  var container = document.getElementById('accounts');
555
794
  var sorted = data.accounts.slice().sort(function(a, b) {
@@ -563,8 +802,6 @@ function renderAccounts(data) {
563
802
  container.innerHTML = sorted.map(function(a) {
564
803
  var isActive = a.status === 'active';
565
804
  var isCooldown = a.status === 'cooldown' || a.status === 'exhausted';
566
- var isDisabled = a.status === 'disabled' || a.status === 'flagged';
567
-
568
805
  var cooldownPercent = 0;
569
806
  if (isCooldown && a.cooldownRemaining > 0) {
570
807
  var totalCooldown = a.cooldownUntil - (a.lastUsed || now);
@@ -594,18 +831,20 @@ function renderAccounts(data) {
594
831
  (a.lastUsed ? formatTime(a.lastUsed) : '--') + '</div></div>' +
595
832
  (isCooldown ? '<div class="card-stat"><div class="stat-label">Cooldown</div><div class="stat-value" style="color:var(--yellow)">' +
596
833
  formatDuration(a.cooldownRemaining) + '</div></div>' : '') +
834
+ (a.inFlightRequests > 0 ? '<div class="card-stat"><div class="stat-label">In Flight</div><div class="stat-value" style="color:var(--blue)">' +
835
+ a.inFlightRequests + '</div></div>' : '') +
597
836
  '<div class="card-stat"><div class="stat-label">Token</div><div class="stat-value" style="color:' +
598
837
  (a.hasValidToken ? 'var(--green)' : 'var(--text-dim)') + '">' +
599
838
  (a.hasValidToken ? 'Valid' : 'Expired') + '</div></div>' +
600
839
  '</div>' +
601
840
  (a.lastError ? '<div class="card-error">' + a.lastError.slice(0, 150) + '</div>' +
602
841
  (a.lastError.toLowerCase().includes('verif') ?
603
- '<div class="card-hint">Fix: Open Antigravity IDE, sign in with this account, and complete the verification prompt. Then click Re-enable.</div>' :
842
+ '<div class="card-hint">Open Antigravity IDE, sign in with this account, and resolve the verification prompt outside the rotator. Keep the account quarantined until that is complete.</div>' :
604
843
  a.lastError.toLowerCase().includes('terms of service') ?
605
- '<div class="card-hint">This account was suspended by Google. Submit an appeal at <a href="https://support.google.com/accounts/troubleshooter/2402620" target="_blank" style="color:var(--cyan)">Google Account Recovery</a>, then Re-enable.</div>' :
844
+ '<div class="card-hint">This account was suspended by Google. Submit an appeal at <a href="https://support.google.com/accounts/troubleshooter/2402620" target="_blank" style="color:var(--blue)">Google Account Recovery</a> and keep it out of rotation unless Google explicitly restores access.</div>' :
606
845
  '') : '') +
607
- (isDisabled ? '<div class="card-actions"><button class="btn-enable" onclick="enableAccount(\\'' +
608
- a.email + '\\')">Re-enable</button></div>' : '') +
846
+ (isCooldown ? '<div class="card-hint">Cooling down after a provider rate-limit response. The rotator will wait for the retry window instead of forcing more traffic into this account.</div>' : '') +
847
+ (a.status === 'disabled' ? '<div class="card-actions"><button class="btn-enable" onclick="enableAccount(\\'' + a.email + '\\')">Re-enable</button></div>' : '') +
609
848
  (isCooldown && cooldownPercent > 0 ? '<div class="cooldown-bar" style="width:' + cooldownPercent + '%"></div>' : '') +
610
849
  '</div>';
611
850
  }).join('');
@@ -613,7 +852,113 @@ function renderAccounts(data) {
613
852
  renderProAdvisor(data.proAdvisor);
614
853
  }
615
854
 
855
+ function renderHealthPill(label, value) {
856
+ return '<div class="health-pill"><div class="label">' + label + '</div><div class="value">' + value + '</div></div>';
857
+ }
858
+
859
+ function renderAttentionPanel(data) {
860
+ var panel = document.getElementById('attentionPanel');
861
+ var accounts = data.accounts || [];
862
+ var flagged = accounts.filter(function(a) { return a.status === 'flagged'; });
863
+ var disabled = accounts.filter(function(a) { return a.status === 'disabled'; });
864
+ var errors = accounts.filter(function(a) { return a.status === 'error'; });
865
+ var cooldown = accounts
866
+ .filter(function(a) { return a.status === 'cooldown'; })
867
+ .sort(function(a, b) { return a.cooldownRemaining - b.cooldownRemaining; })
868
+ .slice(0, 4);
869
+ var items = [];
870
+
871
+ if (flagged.length > 0) {
872
+ items.push(renderAttentionItem(
873
+ 'Flagged by provider',
874
+ flagged.length + ' account(s) are quarantined after a provider enforcement signal. Keep them out of rotation until the provider explicitly restores access.',
875
+ flagged.map(function(a) { return maskText(a.label); }).join(', ')
876
+ ));
877
+ }
878
+ if (cooldown.length > 0) {
879
+ items.push(renderAttentionItem(
880
+ 'Cooling down',
881
+ 'These are the next accounts expected to come back. Routing waits for their retry windows instead of forcing traffic into them.',
882
+ cooldown.map(function(a) { return maskText(a.label) + ' ' + formatDuration(a.cooldownRemaining); }).join(' | ')
883
+ ));
884
+ }
885
+ if (disabled.length > 0) {
886
+ items.push(renderAttentionItem(
887
+ 'Disabled accounts',
888
+ 'These accounts hit repeated operational errors and were taken out of service. Re-enable only after the underlying problem is fixed.',
889
+ disabled.map(function(a) { return maskText(a.label); }).join(', ')
890
+ ));
891
+ }
892
+ if (errors.length > 0) {
893
+ items.push(renderAttentionItem(
894
+ 'Recent errors',
895
+ 'These accounts are still visible but currently erroring. Review the per-account error details below before they escalate to disabled.',
896
+ errors.map(function(a) { return maskText(a.label); }).join(', ')
897
+ ));
898
+ }
899
+
900
+ if (items.length === 0) {
901
+ panel.style.display = 'none';
902
+ panel.innerHTML = '';
903
+ return;
904
+ }
905
+
906
+ panel.style.display = 'block';
907
+ panel.innerHTML = '<div class="operator-title">Attention Needed</div><div class="operator-list">' + items.join('') + '</div>';
908
+ }
909
+
910
+ function renderAttentionItem(title, description, meta) {
911
+ return '<div class="operator-item">' +
912
+ '<div><strong>' + title + '</strong><span>' + description + '</span></div>' +
913
+ '<div class="operator-meta">' + meta + '</div>' +
914
+ '</div>';
915
+ }
916
+
917
+ function renderRecentEvents(events) {
918
+ var panel = document.getElementById('recentEventsPanel');
919
+ var allEvents = events || [];
920
+ if (allEvents.length === 0) {
921
+ panel.style.display = 'none';
922
+ panel.innerHTML = '';
923
+ return;
924
+ }
925
+
926
+ var list = allEvents.filter(matchesEventFilter).slice(0, 14);
927
+ var toolbar =
928
+ '<div class="events-toolbar">' +
929
+ renderEventFilterButton('all', 'All') +
930
+ renderEventFilterButton('errors', 'Errors Only') +
931
+ renderEventFilterButton('proxy', 'Proxy Only') +
932
+ renderEventFilterButton('rotator', 'Rotator Only') +
933
+ '</div>';
934
+ var rows = list.map(function(event) {
935
+ return '<div class="event-item level-' + (event.level || 'info') + '">' +
936
+ '<div class="event-time">' + formatTime(event.timestamp) + '</div>' +
937
+ '<div class="event-source ' + event.source + '">' + escapeHtml(event.source) + '</div>' +
938
+ '<div class="event-message">' + escapeHtml(event.message) + '</div>' +
939
+ '</div>';
940
+ }).join('');
941
+
942
+ panel.style.display = 'block';
943
+ panel.innerHTML =
944
+ '<div class="operator-title">Recent Events</div>' +
945
+ toolbar +
946
+ (rows ? '<div class="events-list">' + rows + '</div>' : '<div class="events-empty">No events match the current filter.</div>');
947
+ }
948
+
949
+ function renderEventFilterButton(filter, label) {
950
+ return '<button class="event-filter' + (EVENT_FILTER === filter ? ' active' : '') + '" onclick="setEventFilter(&quot;' + filter + '&quot;)">' + label + '</button>';
951
+ }
952
+
953
+ function matchesEventFilter(event) {
954
+ if (EVENT_FILTER === 'errors') return event.level === 'error';
955
+ if (EVENT_FILTER === 'proxy') return event.source === 'proxy';
956
+ if (EVENT_FILTER === 'rotator') return event.source === 'rotator';
957
+ return true;
958
+ }
959
+
616
960
  var MASK_MODE = new URLSearchParams(window.location.search).has('mask');
961
+ var EVENT_FILTER = new URLSearchParams(window.location.search).get('events') || 'all';
617
962
  var maskCounter = 0;
618
963
  var maskMap = {};
619
964
 
@@ -632,6 +977,20 @@ function maskEmail(email) {
632
977
  return masked.toLowerCase().replace(/ /g, '-') + '@***.com';
633
978
  }
634
979
 
980
+ function escapeHtml(text) {
981
+ return String(text)
982
+ .replace(/&/g, '&amp;')
983
+ .replace(/</g, '&lt;')
984
+ .replace(/>/g, '&gt;')
985
+ .replace(/\"/g, '&quot;')
986
+ .replace(/'/g, '&#39;');
987
+ }
988
+
989
+ function setEventFilter(filter) {
990
+ EVENT_FILTER = filter;
991
+ refresh();
992
+ }
993
+
635
994
  async function enableAccount(email) {
636
995
  await fetch('/api/enable/' + encodeURIComponent(email), { method: 'POST' });
637
996
  refresh();
package/src/index.ts CHANGED
@@ -28,6 +28,10 @@ function loadConfig(): Config {
28
28
  config.requestsPerRotation = config.requestsPerRotation || 5;
29
29
  config.rotateOnQuotaDrop = config.rotateOnQuotaDrop ?? 20;
30
30
  config.quotaPollIntervalMs = config.quotaPollIntervalMs || 300_000;
31
+ config.maxConcurrentRequestsPerAccount = config.maxConcurrentRequestsPerAccount ?? 1;
32
+ config.protectivePauseMs = config.protectivePauseMs ?? 6 * 60 * 60 * 1000;
33
+ config.useRequestCountRotationWhenQuotaUnknownOnly =
34
+ config.useRequestCountRotationWhenQuotaUnknownOnly ?? true;
31
35
 
32
36
  return config;
33
37
  } catch (err) {
@@ -44,6 +48,8 @@ export function main(): void {
44
48
  console.log(`Loaded ${config.accounts.length} accounts`);
45
49
  console.log(`Rotation: ${config.requestsPerRotation} requests / ${config.rotateOnQuotaDrop}% quota drop`);
46
50
  console.log(`Quota poll: every ${Math.round((config.quotaPollIntervalMs || 300000) / 1000)}s`);
51
+ console.log(`Concurrency cap: ${config.maxConcurrentRequestsPerAccount} request/account`);
52
+ console.log(`Protective pause: ${Math.round((config.protectivePauseMs || 0) / 3600000)}h after serious flag`);
47
53
  console.log();
48
54
 
49
55
  for (const account of config.accounts) {
package/src/login.ts CHANGED
@@ -138,6 +138,9 @@ interface AccountsConfig {
138
138
  requestsPerRotation: number;
139
139
  rotateOnQuotaDrop: number;
140
140
  quotaPollIntervalMs: number;
141
+ maxConcurrentRequestsPerAccount: number;
142
+ protectivePauseMs: number;
143
+ useRequestCountRotationWhenQuotaUnknownOnly: boolean;
141
144
  accounts: AccountEntry[];
142
145
  }
143
146
 
@@ -154,6 +157,9 @@ function loadOrCreateAccountsConfig(): AccountsConfig {
154
157
  requestsPerRotation: 5,
155
158
  rotateOnQuotaDrop: 20,
156
159
  quotaPollIntervalMs: 300000,
160
+ maxConcurrentRequestsPerAccount: 1,
161
+ protectivePauseMs: 21600000,
162
+ useRequestCountRotationWhenQuotaUnknownOnly: true,
157
163
  accounts: [],
158
164
  };
159
165
  }