@jsonstudio/rcc 0.89.1562 → 0.89.1803

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.
Files changed (223) hide show
  1. package/README.md +97 -13
  2. package/configsamples/config.json +8 -8
  3. package/configsamples/config.reference.json +1 -1
  4. package/configsamples/provider/crs/config.v1.json +1 -1
  5. package/configsamples/provider/glm/config.v1.json +1 -1
  6. package/configsamples/provider/glm-anthropic/config.v1.json +1 -1
  7. package/configsamples/provider/kimi/config.v1.json +1 -1
  8. package/configsamples/provider/lmstudio/config.v1.json +2 -1
  9. package/configsamples/provider/mimo/config.v1.json +1 -1
  10. package/configsamples/provider/modelscope/config.v1.json +1 -1
  11. package/configsamples/provider/qwen/config.v1.json +1 -1
  12. package/configsamples/provider/tab/config.v1.json +2 -1
  13. package/configsamples/provider/tabglm/config.v1.json +1 -1
  14. package/dist/build-info.js +3 -3
  15. package/dist/build-info.js.map +1 -1
  16. package/dist/cli/commands/config.js +8 -9
  17. package/dist/cli/commands/config.js.map +1 -1
  18. package/dist/cli/commands/restart.d.ts +4 -12
  19. package/dist/cli/commands/restart.js +226 -120
  20. package/dist/cli/commands/restart.js.map +1 -1
  21. package/dist/cli/commands/start.d.ts +1 -0
  22. package/dist/cli/commands/start.js +28 -1
  23. package/dist/cli/commands/start.js.map +1 -1
  24. package/dist/cli/commands/status.js +12 -6
  25. package/dist/cli/commands/status.js.map +1 -1
  26. package/dist/cli/config/init-provider-catalog.js +12 -11
  27. package/dist/cli/config/init-provider-catalog.js.map +1 -1
  28. package/dist/cli.js +3 -14
  29. package/dist/cli.js.map +1 -1
  30. package/dist/client/anthropic/anthropic-protocol-client.d.ts +1 -0
  31. package/dist/client/anthropic/anthropic-protocol-client.js +25 -0
  32. package/dist/client/anthropic/anthropic-protocol-client.js.map +1 -1
  33. package/dist/commands/oauth.js +185 -9
  34. package/dist/commands/oauth.js.map +1 -1
  35. package/dist/commands/token-daemon.js +12 -2
  36. package/dist/commands/token-daemon.js.map +1 -1
  37. package/dist/docs/daemon-admin-ui.html +1242 -234
  38. package/dist/index.js +119 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/manager/index.d.ts +2 -0
  41. package/dist/manager/index.js +39 -2
  42. package/dist/manager/index.js.map +1 -1
  43. package/dist/manager/modules/quota/antigravity-quota-manager.d.ts +29 -5
  44. package/dist/manager/modules/quota/antigravity-quota-manager.js +369 -113
  45. package/dist/manager/modules/quota/antigravity-quota-manager.js.map +1 -1
  46. package/dist/manager/modules/quota/provider-quota-daemon.cooldown.d.ts +7 -0
  47. package/dist/manager/modules/quota/provider-quota-daemon.cooldown.js +61 -0
  48. package/dist/manager/modules/quota/provider-quota-daemon.cooldown.js.map +1 -1
  49. package/dist/manager/modules/quota/provider-quota-daemon.d.ts +1 -0
  50. package/dist/manager/modules/quota/provider-quota-daemon.events.js +134 -5
  51. package/dist/manager/modules/quota/provider-quota-daemon.events.js.map +1 -1
  52. package/dist/manager/modules/quota/provider-quota-daemon.js +19 -13
  53. package/dist/manager/modules/quota/provider-quota-daemon.js.map +1 -1
  54. package/dist/manager/modules/quota/provider-quota-daemon.model-backoff.d.ts +1 -0
  55. package/dist/manager/modules/quota/provider-quota-daemon.model-backoff.js +8 -3
  56. package/dist/manager/modules/quota/provider-quota-daemon.model-backoff.js.map +1 -1
  57. package/dist/manager/modules/token/index.js +2 -2
  58. package/dist/manager/modules/token/index.js.map +1 -1
  59. package/dist/manager/quota/provider-quota-center.d.ts +9 -0
  60. package/dist/manager/quota/provider-quota-center.js +19 -2
  61. package/dist/manager/quota/provider-quota-center.js.map +1 -1
  62. package/dist/modules/llmswitch/bridge.d.ts +33 -1
  63. package/dist/modules/llmswitch/bridge.js +170 -2
  64. package/dist/modules/llmswitch/bridge.js.map +1 -1
  65. package/dist/modules/llmswitch/core-loader.js +64 -11
  66. package/dist/modules/llmswitch/core-loader.js.map +1 -1
  67. package/dist/modules/pipeline/utils/debug-logger.d.ts +1 -0
  68. package/dist/modules/pipeline/utils/debug-logger.js +50 -3
  69. package/dist/modules/pipeline/utils/debug-logger.js.map +1 -1
  70. package/dist/providers/auth/apikey-auth.js +15 -3
  71. package/dist/providers/auth/apikey-auth.js.map +1 -1
  72. package/dist/providers/auth/oauth-auth.js +26 -2
  73. package/dist/providers/auth/oauth-auth.js.map +1 -1
  74. package/dist/providers/auth/oauth-lifecycle.d.ts +13 -1
  75. package/dist/providers/auth/oauth-lifecycle.js +346 -45
  76. package/dist/providers/auth/oauth-lifecycle.js.map +1 -1
  77. package/dist/providers/auth/oauth-repair-cooldown.d.ts +21 -0
  78. package/dist/providers/auth/oauth-repair-cooldown.js +100 -0
  79. package/dist/providers/auth/oauth-repair-cooldown.js.map +1 -0
  80. package/dist/providers/auth/oauth-repair-env.d.ts +1 -0
  81. package/dist/providers/auth/oauth-repair-env.js +79 -0
  82. package/dist/providers/auth/oauth-repair-env.js.map +1 -0
  83. package/dist/providers/auth/qwen-userinfo-helper.d.ts +2 -0
  84. package/dist/providers/auth/qwen-userinfo-helper.js +72 -40
  85. package/dist/providers/auth/qwen-userinfo-helper.js.map +1 -1
  86. package/dist/providers/auth/tokenfile-auth.d.ts +2 -0
  87. package/dist/providers/auth/tokenfile-auth.js +163 -21
  88. package/dist/providers/auth/tokenfile-auth.js.map +1 -1
  89. package/dist/providers/core/api/provider-types.d.ts +10 -0
  90. package/dist/providers/core/config/camoufox-launcher.d.ts +3 -0
  91. package/dist/providers/core/config/camoufox-launcher.js +190 -3
  92. package/dist/providers/core/config/camoufox-launcher.js.map +1 -1
  93. package/dist/providers/core/config/oauth-flows.js +50 -19
  94. package/dist/providers/core/config/oauth-flows.js.map +1 -1
  95. package/dist/providers/core/config/provider-oauth-configs.js +1 -1
  96. package/dist/providers/core/config/provider-oauth-configs.js.map +1 -1
  97. package/dist/providers/core/runtime/gemini-cli-http-provider.d.ts +5 -0
  98. package/dist/providers/core/runtime/gemini-cli-http-provider.js +172 -15
  99. package/dist/providers/core/runtime/gemini-cli-http-provider.js.map +1 -1
  100. package/dist/providers/core/runtime/gemini-http-provider.d.ts +11 -0
  101. package/dist/providers/core/runtime/gemini-http-provider.js +281 -3
  102. package/dist/providers/core/runtime/gemini-http-provider.js.map +1 -1
  103. package/dist/providers/core/runtime/http-request-executor.js +55 -0
  104. package/dist/providers/core/runtime/http-request-executor.js.map +1 -1
  105. package/dist/providers/core/runtime/http-transport-provider.js +10 -14
  106. package/dist/providers/core/runtime/http-transport-provider.js.map +1 -1
  107. package/dist/providers/core/runtime/provider-factory.d.ts +1 -0
  108. package/dist/providers/core/runtime/provider-factory.js +40 -2
  109. package/dist/providers/core/runtime/provider-factory.js.map +1 -1
  110. package/dist/providers/core/strategies/oauth-auth-code-flow.js +45 -2
  111. package/dist/providers/core/strategies/oauth-auth-code-flow.js.map +1 -1
  112. package/dist/providers/core/strategies/oauth-device-flow.js +13 -2
  113. package/dist/providers/core/strategies/oauth-device-flow.js.map +1 -1
  114. package/dist/providers/core/strategies/oauth-refresh-errors.d.ts +1 -0
  115. package/dist/providers/core/strategies/oauth-refresh-errors.js +26 -0
  116. package/dist/providers/core/strategies/oauth-refresh-errors.js.map +1 -0
  117. package/dist/providers/core/utils/snapshot-writer.d.ts +4 -2
  118. package/dist/providers/core/utils/snapshot-writer.js +86 -23
  119. package/dist/providers/core/utils/snapshot-writer.js.map +1 -1
  120. package/dist/scripts/camoufox/launch-auth.mjs +545 -49
  121. package/dist/server/handlers/chat-handler.js +1 -1
  122. package/dist/server/handlers/chat-handler.js.map +1 -1
  123. package/dist/server/handlers/handler-utils.d.ts +1 -0
  124. package/dist/server/handlers/handler-utils.js +231 -3
  125. package/dist/server/handlers/handler-utils.js.map +1 -1
  126. package/dist/server/handlers/messages-handler.js +1 -1
  127. package/dist/server/handlers/messages-handler.js.map +1 -1
  128. package/dist/server/handlers/responses-handler.js +17 -5
  129. package/dist/server/handlers/responses-handler.js.map +1 -1
  130. package/dist/server/handlers/sse-dispatcher.js +10 -1
  131. package/dist/server/handlers/sse-dispatcher.js.map +1 -1
  132. package/dist/server/runtime/http-server/daemon-admin/control-handler.d.ts +3 -0
  133. package/dist/server/runtime/http-server/daemon-admin/control-handler.js +389 -0
  134. package/dist/server/runtime/http-server/daemon-admin/control-handler.js.map +1 -0
  135. package/dist/server/runtime/http-server/daemon-admin/credentials-handler.js +190 -5
  136. package/dist/server/runtime/http-server/daemon-admin/credentials-handler.js.map +1 -1
  137. package/dist/server/runtime/http-server/daemon-admin/providers-handler.js +2 -1
  138. package/dist/server/runtime/http-server/daemon-admin/providers-handler.js.map +1 -1
  139. package/dist/server/runtime/http-server/daemon-admin/quota-handler.js +116 -14
  140. package/dist/server/runtime/http-server/daemon-admin/quota-handler.js.map +1 -1
  141. package/dist/server/runtime/http-server/daemon-admin/routing-policy.d.ts +30 -0
  142. package/dist/server/runtime/http-server/daemon-admin/routing-policy.js +133 -0
  143. package/dist/server/runtime/http-server/daemon-admin/routing-policy.js.map +1 -0
  144. package/dist/server/runtime/http-server/daemon-admin/status-handler.js +40 -1
  145. package/dist/server/runtime/http-server/daemon-admin/status-handler.js.map +1 -1
  146. package/dist/server/runtime/http-server/daemon-admin-routes.d.ts +5 -0
  147. package/dist/server/runtime/http-server/daemon-admin-routes.js +3 -0
  148. package/dist/server/runtime/http-server/daemon-admin-routes.js.map +1 -1
  149. package/dist/server/runtime/http-server/executor-pipeline.d.ts +10 -0
  150. package/dist/server/runtime/http-server/executor-pipeline.js +6 -0
  151. package/dist/server/runtime/http-server/executor-pipeline.js.map +1 -1
  152. package/dist/server/runtime/http-server/executor-response.js +26 -0
  153. package/dist/server/runtime/http-server/executor-response.js.map +1 -1
  154. package/dist/server/runtime/http-server/hub-shadow-compare.js +41 -3
  155. package/dist/server/runtime/http-server/hub-shadow-compare.js.map +1 -1
  156. package/dist/server/runtime/http-server/index.d.ts +9 -0
  157. package/dist/server/runtime/http-server/index.js +337 -91
  158. package/dist/server/runtime/http-server/index.js.map +1 -1
  159. package/dist/server/runtime/http-server/middleware.js +27 -1
  160. package/dist/server/runtime/http-server/middleware.js.map +1 -1
  161. package/dist/server/runtime/http-server/request-executor.js +159 -24
  162. package/dist/server/runtime/http-server/request-executor.js.map +1 -1
  163. package/dist/server/runtime/http-server/routes.d.ts +1 -0
  164. package/dist/server/runtime/http-server/routes.js +36 -3
  165. package/dist/server/runtime/http-server/routes.js.map +1 -1
  166. package/dist/server/runtime/http-server/server-id.d.ts +1 -0
  167. package/dist/server/runtime/http-server/server-id.js +18 -0
  168. package/dist/server/runtime/http-server/server-id.js.map +1 -0
  169. package/dist/server/runtime/http-server/stats-manager.d.ts +2 -0
  170. package/dist/server/runtime/http-server/stats-manager.js +63 -7
  171. package/dist/server/runtime/http-server/stats-manager.js.map +1 -1
  172. package/dist/server/runtime/http-server/types.d.ts +2 -0
  173. package/dist/server/utils/stage-logger.js +54 -9
  174. package/dist/server/utils/stage-logger.js.map +1 -1
  175. package/dist/token-daemon/history-store.d.ts +8 -3
  176. package/dist/token-daemon/history-store.js +41 -20
  177. package/dist/token-daemon/history-store.js.map +1 -1
  178. package/dist/token-daemon/index.d.ts +5 -1
  179. package/dist/token-daemon/index.js +191 -11
  180. package/dist/token-daemon/index.js.map +1 -1
  181. package/dist/token-daemon/quota-auth-issue.d.ts +7 -0
  182. package/dist/token-daemon/quota-auth-issue.js +231 -0
  183. package/dist/token-daemon/quota-auth-issue.js.map +1 -0
  184. package/dist/token-daemon/token-daemon.d.ts +2 -0
  185. package/dist/token-daemon/token-daemon.js +177 -14
  186. package/dist/token-daemon/token-daemon.js.map +1 -1
  187. package/dist/token-portal/local-token-portal.js +6 -0
  188. package/dist/token-portal/local-token-portal.js.map +1 -1
  189. package/docs/ANTIGRAVITY_IDE_FORWARD_PROXY.md +61 -0
  190. package/docs/ANTIGRAVITY_THOUGHT_SIGNATURE_BOOTSTRAP_429.md +80 -0
  191. package/docs/CLOCK.md +94 -0
  192. package/docs/DAEMON_CONTROL_PLANE.md +34 -0
  193. package/docs/OAUTH.md +172 -0
  194. package/docs/PROVIDERS_BUILTIN.md +5 -3
  195. package/docs/PROVIDER_TYPES.md +6 -4
  196. package/docs/QUOTA_MANAGER_V3.md +54 -0
  197. package/docs/ROUTING_POLICY_SCHEMA.md +47 -0
  198. package/docs/ROUTING_POLICY_UI.md +11 -0
  199. package/docs/SERVERTOOL_CLOCK_DESIGN.md +56 -25
  200. package/docs/antigravity-routing-contract.md +17 -11
  201. package/docs/config-secrets.md +49 -0
  202. package/docs/daemon-admin-ui.html +1242 -234
  203. package/docs/oauth-authentication-guide.md +4 -0
  204. package/docs/oauth-iflow-implementation.md +4 -0
  205. package/docs/provider-quota-design.md +11 -0
  206. package/docs/providers/antigravity-gemini-provider-compat.md +1 -0
  207. package/docs/providers/antigravity-thought-signature.md +127 -0
  208. package/docs/providers/tabglm-claude-code-compat.md +11 -3
  209. package/docs/refactoring/host-sharedmodule-safe-migration-plan.md +164 -0
  210. package/docs/token-daemon-preview.html +2 -2
  211. package/docs/token-refresh-daemon-plan.md +6 -6
  212. package/package.json +4 -3
  213. package/scripts/antigravity-ide-forward-proxy.mjs +362 -0
  214. package/scripts/backfill-apply-patch-exec-errorsamples.mjs +19 -0
  215. package/scripts/camoufox/launch-auth.mjs +545 -49
  216. package/scripts/ci/repo-sanity.mjs +2 -0
  217. package/scripts/install-global.sh +46 -0
  218. package/scripts/migrate-antigravity-session-signatures-alias.mjs +193 -0
  219. package/scripts/migrate-antigravity-session-signatures.mjs +165 -0
  220. package/scripts/tests/blackbox-rcc-vs-routecodex-antigravity.mjs +44 -9
  221. package/scripts/tests/ci-jest.mjs +3 -0
  222. package/scripts/verify-client-headers.mjs +33 -5
  223. package/scripts/virtual-router-dryrun.mjs +333 -0
@@ -557,6 +557,40 @@
557
557
  color: rgba(255, 255, 255, 0.82);
558
558
  }
559
559
 
560
+
561
+ .auth-selection-summary {
562
+ display: flex;
563
+ flex-wrap: wrap;
564
+ gap: 8px;
565
+ margin-top: 6px;
566
+ }
567
+
568
+ .auth-mode-panels {
569
+ border: 1px solid var(--border);
570
+ border-radius: 12px;
571
+ padding: 10px;
572
+ background: rgba(0, 0, 0, 0.16);
573
+ margin-bottom: 10px;
574
+ }
575
+
576
+ .auth-mode-panel .section-sub {
577
+ margin-bottom: 8px;
578
+ }
579
+
580
+ .auth-verify-url {
581
+ margin: 8px 0;
582
+ white-space: pre-wrap;
583
+ word-break: break-all;
584
+ }
585
+
586
+ .auth-mode-tabs {
587
+ margin: 10px 0;
588
+ }
589
+
590
+ .auth-mode-tabs .tab {
591
+ min-width: 120px;
592
+ }
593
+
560
594
  .log {
561
595
  white-space: pre-wrap;
562
596
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
@@ -604,6 +638,7 @@
604
638
  <div class="statusline">
605
639
  <div class="pill"><span id="statusDot" class="dot"></span><span id="statusText">connecting…</span></div>
606
640
  <div class="pill"><span class="mono" id="serverId">serverId: —</span></div>
641
+ <div class="pill"><span class="mono" id="serverVersion">version: —</span></div>
607
642
  <button id="restartRuntimeBtn" class="primary">Restart runtime</button>
608
643
  </div>
609
644
  </header>
@@ -660,13 +695,105 @@
660
695
  </div>
661
696
 
662
697
  <div class="tabs">
698
+ <button class="tab" data-tab="control">Control</button>
663
699
  <button class="tab active" data-tab="providers">Provider Pool</button>
700
+ <button class="tab" data-tab="stats">Stats</button>
664
701
  <button class="tab" data-tab="tokens">Token Stats</button>
665
702
  <button class="tab" data-tab="credentials">Auth Provider Pool</button>
666
703
  <button class="tab" data-tab="quota">Quota Pool</button>
667
704
  <button class="tab" data-tab="routing">Runtime Routing Pool</button>
668
705
  </div>
669
706
 
707
+ <section id="panelControl" data-panel="control" style="display:none;">
708
+ <div class="grid grid-wide-left">
709
+ <div class="card" style="box-shadow: none;">
710
+ <p class="section-title">Control plane (single entry)</p>
711
+ <p class="section-sub">
712
+ This panel uses <span class="mono">/daemon/control/snapshot</span> and <span class="mono">/daemon/control/mutate</span>
713
+ to manage all local servers (broadcast restart) and quota operations from one port.
714
+ </p>
715
+ <div class="row" style="margin-bottom: 10px;">
716
+ <button id="controlRefreshBtn" class="primary">Refresh</button>
717
+ <button id="controlRestartAllBtn" class="danger">Restart all servers</button>
718
+ <button id="controlQuotaRefreshBtn">Refresh quota</button>
719
+ <span id="controlHint" class="muted" style="margin-left:auto; font-size:12px;"></span>
720
+ </div>
721
+ <div class="table-wrap">
722
+ <table class="table">
723
+ <thead>
724
+ <tr>
725
+ <th>port</th>
726
+ <th>version</th>
727
+ <th>ready</th>
728
+ <th>pids</th>
729
+ </tr>
730
+ </thead>
731
+ <tbody id="controlServersTbody"></tbody>
732
+ </table>
733
+ </div>
734
+ <p class="section-title" style="margin-top: 12px;">Routing hits (llmswitch-core)</p>
735
+ <div class="table-wrap">
736
+ <table class="table">
737
+ <thead>
738
+ <tr>
739
+ <th>route</th>
740
+ <th>hits</th>
741
+ </tr>
742
+ </thead>
743
+ <tbody id="controlHitsTbody"></tbody>
744
+ </table>
745
+ </div>
746
+ <div id="controlOpLog" class="log" style="margin-top: 10px; display:none;"></div>
747
+ </div>
748
+
749
+ <div class="card" style="box-shadow: none;">
750
+ <p class="section-title">Quota actions</p>
751
+ <p class="section-sub">
752
+ Offline/Recover/Reset are executed by control plane (no direct writes from UI).
753
+ </p>
754
+ <div class="row" style="margin-bottom: 10px;">
755
+ <label for="controlQuotaKeyInput">providerKey</label>
756
+ <input id="controlQuotaKeyInput" type="text" placeholder="tab.key1.gpt-5.2-codex" style="flex: 1; min-width: 320px;" />
757
+ </div>
758
+ <div class="row" style="margin-bottom: 10px;">
759
+ <label for="controlQuotaModeSelect">mode</label>
760
+ <select id="controlQuotaModeSelect" style="width: 140px;">
761
+ <option value="cooldown" selected>cooldown</option>
762
+ <option value="blacklist">blacklist</option>
763
+ </select>
764
+ <label for="controlQuotaDurationSelect">time</label>
765
+ <select id="controlQuotaDurationSelect" style="width: 160px;">
766
+ <option value="5">5m</option>
767
+ <option value="15">15m</option>
768
+ <option value="30">30m</option>
769
+ <option value="60" selected>1h</option>
770
+ <option value="180">3h</option>
771
+ <option value="360">6h</option>
772
+ <option value="720">12h</option>
773
+ <option value="1440">24h</option>
774
+ </select>
775
+ <button id="controlQuotaOfflineBtn" class="danger">Offline</button>
776
+ <button id="controlQuotaRecoverBtn">Recover</button>
777
+ <button id="controlQuotaResetBtn">Reset</button>
778
+ </div>
779
+ <div class="table-wrap">
780
+ <table class="table">
781
+ <thead>
782
+ <tr>
783
+ <th>providerKey</th>
784
+ <th>inPool</th>
785
+ <th>reason</th>
786
+ <th>cooldownUntil</th>
787
+ <th>blacklistUntil</th>
788
+ </tr>
789
+ </thead>
790
+ <tbody id="controlQuotaProvidersTbody"></tbody>
791
+ </table>
792
+ </div>
793
+ </div>
794
+ </div>
795
+ </section>
796
+
670
797
  <section id="panelProviders" data-panel="providers">
671
798
  <div class="grid grid-wide-left">
672
799
  <div class="card" style="box-shadow: none;">
@@ -801,6 +928,132 @@
801
928
  </div>
802
929
  </section>
803
930
 
931
+ <section id="panelStats" data-panel="stats" style="display:none;">
932
+ <div class="grid">
933
+ <div class="card" style="box-shadow:none;">
934
+ <p class="section-title">Stats (session)</p>
935
+ <p class="section-sub">
936
+ Real-time request/error counters aggregated from in-memory buckets (counts provider attempts, including failover retries). Resets on server restart.
937
+ </p>
938
+ <div class="row" style="margin-bottom: 10px;">
939
+ <button id="refreshStatsBtn" class="primary">Refresh</button>
940
+ <label class="muted" style="display:inline-flex; align-items:center; gap:8px;">
941
+ <input id="statsAutoRefresh" type="checkbox" checked />
942
+ auto refresh (2s)
943
+ </label>
944
+ <span class="mono muted" id="statsLastUpdated"></span>
945
+ </div>
946
+ <div class="notice mono" id="statsSessionTotalsBox" style="white-space: pre-wrap;"></div>
947
+ <div class="table-wrap" style="margin-top: 10px;">
948
+ <table class="table">
949
+ <thead>
950
+ <tr>
951
+ <th>providerId</th>
952
+ <th>req</th>
953
+ <th>err</th>
954
+ </tr>
955
+ </thead>
956
+ <tbody id="statsSessionProvidersTbody"></tbody>
957
+ </table>
958
+ </div>
959
+ <div class="table-wrap" style="margin-top: 10px;">
960
+ <table class="table">
961
+ <thead>
962
+ <tr>
963
+ <th>providerRuntime</th>
964
+ <th>req</th>
965
+ <th>err</th>
966
+ </tr>
967
+ </thead>
968
+ <tbody id="statsSessionRuntimesTbody"></tbody>
969
+ </table>
970
+ </div>
971
+ <div class="table-wrap" style="margin-top: 10px;">
972
+ <table class="table">
973
+ <thead>
974
+ <tr>
975
+ <th>model</th>
976
+ <th>req</th>
977
+ <th>err</th>
978
+ </tr>
979
+ </thead>
980
+ <tbody id="statsSessionModelsTbody"></tbody>
981
+ </table>
982
+ </div>
983
+ <div class="table-wrap" style="margin-top: 10px;">
984
+ <table class="table">
985
+ <thead>
986
+ <tr>
987
+ <th>providerKey</th>
988
+ <th>model</th>
989
+ <th>req</th>
990
+ <th>err</th>
991
+ </tr>
992
+ </thead>
993
+ <tbody id="statsSessionErrorsTbody"></tbody>
994
+ </table>
995
+ </div>
996
+ </div>
997
+
998
+ <div class="card" style="box-shadow:none;">
999
+ <p class="section-title">Stats (historical)</p>
1000
+ <p class="section-sub">
1001
+ Aggregated from <span class="mono">~/.routecodex/logs/provider-stats.jsonl</span> (best-effort).
1002
+ </p>
1003
+ <div class="notice mono" id="statsHistoricalTotalsBox" style="white-space: pre-wrap;"></div>
1004
+ <div class="table-wrap" style="margin-top: 10px;">
1005
+ <table class="table">
1006
+ <thead>
1007
+ <tr>
1008
+ <th>providerId</th>
1009
+ <th>req</th>
1010
+ <th>err</th>
1011
+ </tr>
1012
+ </thead>
1013
+ <tbody id="statsHistoricalProvidersTbody"></tbody>
1014
+ </table>
1015
+ </div>
1016
+ <div class="table-wrap" style="margin-top: 10px;">
1017
+ <table class="table">
1018
+ <thead>
1019
+ <tr>
1020
+ <th>providerRuntime</th>
1021
+ <th>req</th>
1022
+ <th>err</th>
1023
+ </tr>
1024
+ </thead>
1025
+ <tbody id="statsHistoricalRuntimesTbody"></tbody>
1026
+ </table>
1027
+ </div>
1028
+ <div class="table-wrap" style="margin-top: 10px;">
1029
+ <table class="table">
1030
+ <thead>
1031
+ <tr>
1032
+ <th>model</th>
1033
+ <th>req</th>
1034
+ <th>err</th>
1035
+ </tr>
1036
+ </thead>
1037
+ <tbody id="statsHistoricalModelsTbody"></tbody>
1038
+ </table>
1039
+ </div>
1040
+ <div class="table-wrap" style="margin-top: 10px;">
1041
+ <table class="table">
1042
+ <thead>
1043
+ <tr>
1044
+ <th>providerKey</th>
1045
+ <th>model</th>
1046
+ <th>req</th>
1047
+ <th>err</th>
1048
+ </tr>
1049
+ </thead>
1050
+ <tbody id="statsHistoricalErrorsTbody"></tbody>
1051
+ </table>
1052
+ </div>
1053
+ </div>
1054
+ </div>
1055
+ </section>
1056
+
804
1057
  <section id="panelTokens" data-panel="tokens" style="display:none;">
805
1058
  <div class="grid">
806
1059
  <div class="card" style="box-shadow:none;">
@@ -833,9 +1086,9 @@
833
1086
  </section>
834
1087
 
835
1088
  <section id="panelCredentials" data-panel="credentials" style="display:none;">
836
- <div class="grid">
1089
+ <div class="grid grid-wide-right">
837
1090
  <div class="card" style="box-shadow:none;">
838
- <p class="section-title">Credentials</p>
1091
+ <p class="section-title">Auth Inventory</p>
839
1092
  <p class="section-sub">
840
1093
  Token files + API key authfiles in <span class="mono">~/.routecodex/auth</span>.
841
1094
  </p>
@@ -860,10 +1113,11 @@
860
1113
  </div>
861
1114
 
862
1115
  <div class="card" style="box-shadow:none;">
863
- <p class="section-title">OAuth / Browser settings</p>
1116
+ <p class="section-title">Auth Workbench</p>
864
1117
  <p class="section-sub">
865
- Set <span class="mono">ROUTECODEX_OAUTH_BROWSER</span> via config so “Authorize” can auto-open with Camoufox.
1118
+ Manual and Auto flows are isolated. Use Manual for explicit user-driven login; use Auto only for provider auto-mode replay.
866
1119
  </p>
1120
+
867
1121
  <div class="row" style="margin-bottom: 10px;">
868
1122
  <label for="oauthBrowserSelect">oauthBrowser</label>
869
1123
  <select id="oauthBrowserSelect">
@@ -873,8 +1127,8 @@
873
1127
  <button id="saveOauthBrowserBtn" class="primary">Save</button>
874
1128
  </div>
875
1129
 
876
- <p class="section-title" style="margin-top: 10px;">Authorize OAuth</p>
877
- <div class="row" style="margin-bottom: 10px;">
1130
+ <p class="section-title" style="margin-top: 10px;">Auth Context</p>
1131
+ <div class="row" style="margin-bottom: 8px;">
878
1132
  <label for="oauthProviderSelect">provider</label>
879
1133
  <select id="oauthProviderSelect">
880
1134
  <option value="qwen">qwen</option>
@@ -885,13 +1139,52 @@
885
1139
  <label for="oauthAuthAliasInput">alias</label>
886
1140
  <input id="oauthAuthAliasInput" type="text" placeholder="default" style="width: 240px;" />
887
1141
  </div>
1142
+ <div class="auth-selection-summary" style="margin-bottom: 10px;">
1143
+ <span id="oauthSelectionStatusPill" class="pill">status: —</span>
1144
+ <span id="oauthSelectionExpiryPill" class="pill">expires: —</span>
1145
+ <span id="oauthSelectionIssuePill" class="pill">issue: —</span>
1146
+ </div>
1147
+
1148
+ <div class="tabs auth-mode-tabs">
1149
+ <button id="oauthModeManualBtn" class="tab active" type="button">Manual Auth</button>
1150
+ <button id="oauthModeAutoBtn" class="tab" type="button">Auto Auth</button>
1151
+ </div>
1152
+
1153
+ <div class="auth-mode-panels">
1154
+ <div id="oauthManualPanel" class="auth-mode-panel">
1155
+ <p class="section-sub">Pure manual mode (no auto selector / no auto confirm injection).</p>
1156
+ <div class="row" style="margin-bottom: 6px;">
1157
+ <label><input id="oauthManualOpenBrowser" type="checkbox" checked /> open browser</label>
1158
+ <label><input id="oauthManualForceReauth" type="checkbox" checked /> force reauthorize</label>
1159
+ <label><input id="oauthManualHeadful" type="checkbox" checked /> headed window</label>
1160
+ <button id="oauthAuthorizeManualBtn" class="primary" type="button">Start Manual Auth</button>
1161
+ </div>
1162
+ </div>
1163
+ <div id="oauthAutoPanel" class="auth-mode-panel" style="display:none;">
1164
+ <p class="section-sub">Auto mode (provider-specific Camoufox auto-mode, fallback handled by runtime).</p>
1165
+ <div class="row" style="margin-bottom: 6px;">
1166
+ <label><input id="oauthAutoOpenBrowser" type="checkbox" checked /> open browser</label>
1167
+ <label><input id="oauthAutoForceReauth" type="checkbox" checked /> force reauthorize</label>
1168
+ <label><input id="oauthAutoHeadful" type="checkbox" /> debug headful</label>
1169
+ <button id="oauthAuthorizeAutoBtn" class="primary" type="button">Run Auto Auth</button>
1170
+ </div>
1171
+ </div>
1172
+ </div>
1173
+
888
1174
  <div id="oauthAuthIssueHint" class="notice" style="display:none; margin-bottom: 10px; white-space: pre-wrap;"></div>
889
- <div class="row" style="margin-bottom: 10px;">
890
- <label><input id="oauthOpenBrowser" type="checkbox" checked /> open browser</label>
891
- <label><input id="oauthForceReauth" type="checkbox" /> force reauthorize</label>
892
- <button id="oauthAuthorizeBtn" class="primary">Authorize</button>
1175
+
1176
+ <div id="oauthVerifyCenter" class="notice" style="display:none; margin-bottom: 10px;">
1177
+ <p class="section-title" style="margin-bottom: 4px;">Verify Center</p>
1178
+ <p class="section-sub" style="margin-bottom: 6px;">If upstream requires account verification, complete it here before re-authorizing.</p>
1179
+ <div id="oauthVerifyUrl" class="mono auth-verify-url">—</div>
1180
+ <div class="row">
1181
+ <button id="oauthVerifyOpenBtn" type="button">Open Verify in Camoufox</button>
1182
+ <button id="oauthVerifyCopyBtn" type="button">Copy Verify URL</button>
1183
+ <button id="oauthVerifyRecheckBtn" type="button">Recheck Status</button>
1184
+ </div>
893
1185
  </div>
894
1186
 
1187
+ <p class="section-title" style="margin-top: 10px;">Run Console</p>
895
1188
  <div id="credentialOpLog" class="log" style="display:none;"></div>
896
1189
  </div>
897
1190
  </div>
@@ -905,16 +1198,20 @@
905
1198
  VirtualRouter consumes this via <span class="mono">quotaView</span>. When
906
1199
  <span class="mono">inPool=false</span>, the provider is treated as removed from the route pool.
907
1200
  </p>
908
- <div class="row" style="margin-bottom: 10px;">
909
- <label for="quotaFilterInput">filter</label>
910
- <input id="quotaFilterInput" type="text" placeholder="providerKey contains…" style="width: 320px;" />
911
- <label><input id="quotaHideOkToggle" type="checkbox" /> hide ok</label>
912
- <label><input id="quotaOnlyRoutedTargetsToggle" type="checkbox" checked /> only routed targets</label>
913
- <span class="muted" style="font-size:12px;">Tip: click a row to fill the offline box.</span>
914
- </div>
915
- <div class="row" style="margin-bottom: 10px;">
916
- <label for="quotaKeyInput">providerKey</label>
917
- <input id="quotaKeyInput" type="text" placeholder="tab.key1.gpt-5.2-codex" style="width: 420px;" />
1201
+ <div class="row" style="margin-bottom: 10px;">
1202
+ <label for="quotaFilterInput">filter</label>
1203
+ <input id="quotaFilterInput" type="text" placeholder="providerKey contains…" style="width: 320px;" />
1204
+ <label><input id="quotaHideOkToggle" type="checkbox" /> hide ok</label>
1205
+ <label><input id="quotaOnlyRoutedTargetsToggle" type="checkbox" checked /> only routed targets</label>
1206
+ <button id="quotaSelectVisibleBtn">Select visible</button>
1207
+ <button id="quotaClearSelectionBtn">Clear selection</button>
1208
+ <label><input id="quotaAutoRefreshToggle" type="checkbox" checked /> auto refresh</label>
1209
+ <span id="quotaSelectionHint" class="muted" style="font-size:12px;">selected 0</span>
1210
+ <span class="muted" style="font-size:12px;">Tip: click a row to fill the offline box.</span>
1211
+ </div>
1212
+ <div class="row" style="margin-bottom: 10px;">
1213
+ <label for="quotaKeyInput">providerKey</label>
1214
+ <input id="quotaKeyInput" type="text" placeholder="tab.key1.gpt-5.2-codex" style="width: 420px;" />
918
1215
  <label for="quotaModeSelect">offline mode</label>
919
1216
  <select id="quotaModeSelect" style="width: 140px;">
920
1217
  <option value="cooldown">cooldown</option>
@@ -934,25 +1231,28 @@
934
1231
  <button id="quotaApplyDisableBtn" class="danger">Offline</button>
935
1232
  <button id="quotaApplyRecoverBtn">Recover</button>
936
1233
  <button id="quotaApplyResetBtn">Reset</button>
937
- </div>
1234
+ </div>
938
1235
  <div class="muted" style="font-size:12px; margin: -6px 0 10px;">
939
1236
  Offline removes the provider from the route pool for the selected minutes. Recover brings it back online immediately.
1237
+ If selection is non-empty, actions apply to selected providerKeys.
940
1238
  </div>
941
- <div class="row" style="margin-bottom: 10px;">
942
- <button id="refreshQuotaBtn" class="primary">Refresh provider pool</button>
943
- <button id="refreshQuotaSnapshotBtn" class="primary">Refresh antigravity snapshot</button>
944
- <button id="resetQuotaBtn" class="danger">Reset provider-quota module</button>
1239
+ <div class="row" style="margin-bottom: 10px;">
1240
+ <button id="refreshQuotaBtn" class="primary">Refresh provider pool</button>
1241
+ <button id="refreshQuotaSnapshotBtn" class="primary">Refresh antigravity snapshot</button>
1242
+ <button id="resetQuotaBtn" class="danger">Reset provider-quota module</button>
945
1243
  </div>
946
1244
  <div class="table-wrap">
947
- <table class="table">
948
- <thead>
949
- <tr>
950
- <th>provider</th>
951
- <th>key</th>
952
- <th>model</th>
953
- <th>inPool</th>
954
- <th>reason</th>
955
- <th>until</th>
1245
+ <table class="table">
1246
+ <thead>
1247
+ <tr>
1248
+ <th style="width: 34px;">
1249
+ <input id="quotaSelectAllVisibleToggle" type="checkbox" title="Select all visible" />
1250
+ </th>
1251
+ <th>key</th>
1252
+ <th>model</th>
1253
+ <th>inPool</th>
1254
+ <th>reason</th>
1255
+ <th>until</th>
956
1256
  <th>errCount</th>
957
1257
  <th></th>
958
1258
  </tr>
@@ -1056,19 +1356,26 @@
1056
1356
 
1057
1357
  <script>
1058
1358
  const $ = (id) => document.getElementById(id);
1059
- const UI = {
1060
- selectedProviderId: "",
1061
- lastUnauthorizedToastAt: 0,
1062
- adminAuth: null,
1063
- quotaProviders: [],
1064
- quotaProvidersUpdatedAt: 0,
1065
- quotaProviderMap: null,
1066
- routingTargets: null,
1067
- routingTargetsUpdatedAt: 0,
1068
- routingSources: [],
1069
- routingSourcesUpdatedAt: 0,
1070
- routingLocation: "virtualrouter.routing"
1071
- };
1359
+ const UI = {
1360
+ selectedProviderId: "",
1361
+ lastUnauthorizedToastAt: 0,
1362
+ adminAuth: null,
1363
+ controlSnapshot: null,
1364
+ controlSnapshotUpdatedAt: 0,
1365
+ quotaProviders: [],
1366
+ quotaProvidersUpdatedAt: 0,
1367
+ quotaProviderMap: null,
1368
+ quotaSelection: new Set(),
1369
+ quotaRenderedVisibleKeys: [],
1370
+ quotaRenderedGroups: new Map(),
1371
+ routingTargets: null,
1372
+ routingTargetsUpdatedAt: 0,
1373
+ routingSources: [],
1374
+ routingSourcesUpdatedAt: 0,
1375
+ routingLocation: "virtualrouter.routing",
1376
+ credentials: [],
1377
+ oauthMode: "manual"
1378
+ };
1072
1379
  let toastTimer = null;
1073
1380
 
1074
1381
  function toast(msg, kind = "err") {
@@ -1157,7 +1464,9 @@
1157
1464
  btn.classList.toggle("active", btn.getAttribute("data-tab") === name);
1158
1465
  });
1159
1466
  const panels = [
1467
+ { name: "control", el: $("panelControl") },
1160
1468
  { name: "providers", el: $("panelProviders") },
1469
+ { name: "stats", el: $("panelStats") },
1161
1470
  { name: "tokens", el: $("panelTokens") },
1162
1471
  { name: "credentials", el: $("panelCredentials") },
1163
1472
  { name: "quota", el: $("panelQuota") },
@@ -1167,6 +1476,7 @@
1167
1476
 
1168
1477
  // Light auto-refresh on tab switch to avoid showing stale "Unauthorized" after login.
1169
1478
  void maybeRefreshTab(name);
1479
+ syncStatsAutoRefresh();
1170
1480
  }
1171
1481
 
1172
1482
  function getActiveTab() {
@@ -1176,7 +1486,9 @@
1176
1486
  }
1177
1487
 
1178
1488
  const tabLastRefreshedAt = {
1489
+ control: 0,
1179
1490
  providers: 0,
1491
+ stats: 0,
1180
1492
  tokens: 0,
1181
1493
  credentials: 0,
1182
1494
  quota: 0,
@@ -1192,6 +1504,8 @@
1192
1504
  tabLastRefreshedAt[key] = now;
1193
1505
  try {
1194
1506
  if (key === "providers") await refreshProviders();
1507
+ else if (key === "control") await refreshControl();
1508
+ else if (key === "stats") await refreshStats();
1195
1509
  else if (key === "tokens") await refreshTokens();
1196
1510
  else if (key === "credentials") await refreshCredentials();
1197
1511
  else if (key === "quota") {
@@ -1207,6 +1521,32 @@
1207
1521
  }
1208
1522
  }
1209
1523
 
1524
+ let statsAutoRefreshTimer = null;
1525
+ function syncStatsAutoRefresh() {
1526
+ if (statsAutoRefreshTimer) {
1527
+ try { clearInterval(statsAutoRefreshTimer); } catch {}
1528
+ statsAutoRefreshTimer = null;
1529
+ }
1530
+ const active = getActiveTab();
1531
+ if (active !== "stats") {
1532
+ return;
1533
+ }
1534
+ const checkbox = $("statsAutoRefresh");
1535
+ const enabled = checkbox && checkbox.checked;
1536
+ if (!enabled) {
1537
+ return;
1538
+ }
1539
+ if (UI && UI.adminAuth && UI.adminAuth.authenticated === false) {
1540
+ return;
1541
+ }
1542
+ statsAutoRefreshTimer = setInterval(() => {
1543
+ if (getActiveTab() !== "stats") return;
1544
+ const checkboxNow = $("statsAutoRefresh");
1545
+ if (!checkboxNow || !checkboxNow.checked) return;
1546
+ void refreshStats();
1547
+ }, 2000);
1548
+ }
1549
+
1210
1550
  function textOf(value) {
1211
1551
  if (value === null || value === undefined) return "";
1212
1552
  return String(value);
@@ -1231,6 +1571,16 @@
1231
1571
  return tr;
1232
1572
  }
1233
1573
 
1574
+ function createInfoRow(colSpan, message) {
1575
+ const tr = document.createElement("tr");
1576
+ const td = document.createElement("td");
1577
+ td.colSpan = colSpan;
1578
+ td.className = "mono muted";
1579
+ td.textContent = textOf(message);
1580
+ tr.appendChild(td);
1581
+ return tr;
1582
+ }
1583
+
1234
1584
  function setSelectedProviderId(id) {
1235
1585
  UI.selectedProviderId = textOf(id || "");
1236
1586
  const tbody = $("providersTbody");
@@ -1751,6 +2101,14 @@
1751
2101
  }
1752
2102
 
1753
2103
  async function refreshStatus() {
2104
+ // /health is public; use it to always show version even before admin login.
2105
+ try {
2106
+ const res = await fetch("/health");
2107
+ const data = res && res.ok ? await res.json().catch(() => null) : null;
2108
+ $("serverVersion").textContent = `version: ${data && data.version ? data.version : "—"}`;
2109
+ } catch {
2110
+ $("serverVersion").textContent = "version: —";
2111
+ }
1754
2112
  try {
1755
2113
  const data = await apiFetch("/daemon/status");
1756
2114
  $("serverId").textContent = `serverId: ${data.serverId || "—"}`;
@@ -1761,9 +2119,136 @@
1761
2119
  $("statusText").textContent = e && e.status === 401 ? "401 (set API key above)" : "disconnected";
1762
2120
  $("statusDot").classList.remove("ok");
1763
2121
  $("statusDot").classList.add("err");
2122
+ $("serverId").textContent = "serverId: —";
1764
2123
  }
1765
2124
  }
1766
2125
 
2126
+ function formatTs(ms) {
2127
+ if (ms === null || ms === undefined) return "—";
2128
+ const n = Number(ms);
2129
+ if (!Number.isFinite(n) || n <= 0) return "—";
2130
+ try {
2131
+ return new Date(n).toLocaleString();
2132
+ } catch {
2133
+ return String(n);
2134
+ }
2135
+ }
2136
+
2137
+ function renderControlSnapshot() {
2138
+ const snap = UI.controlSnapshot;
2139
+ const serversBody = $("controlServersTbody");
2140
+ const hitsBody = $("controlHitsTbody");
2141
+ const quotaBody = $("controlQuotaProvidersTbody");
2142
+ if (serversBody) serversBody.replaceChildren();
2143
+ if (hitsBody) hitsBody.replaceChildren();
2144
+ if (quotaBody) quotaBody.replaceChildren();
2145
+ if (!snap || typeof snap !== "object") {
2146
+ if (serversBody) serversBody.appendChild(createErrorRow(4, "No snapshot"));
2147
+ if (hitsBody) hitsBody.appendChild(createErrorRow(2, "No snapshot"));
2148
+ if (quotaBody) quotaBody.appendChild(createErrorRow(5, "No snapshot"));
2149
+ return;
2150
+ }
2151
+ const servers = Array.isArray(snap.servers) ? snap.servers : [];
2152
+ if (!servers.length) {
2153
+ if (serversBody) serversBody.appendChild(createErrorRow(4, "No local servers discovered"));
2154
+ } else if (serversBody) {
2155
+ for (const s of servers) {
2156
+ const tr = document.createElement("tr");
2157
+ tr.appendChild(createCell("td", String(s.port ?? "—"), "mono"));
2158
+ tr.appendChild(createCell("td", textOf(s.version || "—"), "mono"));
2159
+ tr.appendChild(createCell("td", s.ready === true ? "true" : s.ready === false ? "false" : "—", ""));
2160
+ tr.appendChild(createCell("td", Array.isArray(s.pids) ? s.pids.join(" ") : "", "mono"));
2161
+ serversBody.appendChild(tr);
2162
+ }
2163
+ }
2164
+
2165
+ try {
2166
+ const llms = snap.llmsStats && typeof snap.llmsStats === "object" ? snap.llmsStats : null;
2167
+ const routeHits =
2168
+ llms && llms.router && llms.router.global && llms.router.global.routeHitCount
2169
+ ? llms.router.global.routeHitCount
2170
+ : null;
2171
+ const entries = routeHits && typeof routeHits === "object" ? Object.entries(routeHits) : [];
2172
+ entries.sort((a, b) => Number(b[1] || 0) - Number(a[1] || 0));
2173
+ const top = entries.slice(0, 20);
2174
+ if (!top.length) {
2175
+ if (hitsBody) hitsBody.appendChild(createErrorRow(2, "No routing hits recorded yet"));
2176
+ } else if (hitsBody) {
2177
+ for (const [route, count] of top) {
2178
+ const tr = document.createElement("tr");
2179
+ tr.appendChild(createCell("td", String(route || "—"), "mono"));
2180
+ tr.appendChild(createCell("td", String(count ?? 0), "mono"));
2181
+ hitsBody.appendChild(tr);
2182
+ }
2183
+ }
2184
+ } catch {
2185
+ if (hitsBody) hitsBody.appendChild(createErrorRow(2, "Failed to read routing hits"));
2186
+ }
2187
+
2188
+ const quota = snap.quota && typeof snap.quota === "object" ? snap.quota : null;
2189
+ const providers = quota && Array.isArray(quota.providers) ? quota.providers : [];
2190
+ const list = providers
2191
+ .map((p) => ({
2192
+ providerKey: textOf(p.providerKey),
2193
+ inPool: Boolean(p.inPool),
2194
+ reason: textOf(p.reason),
2195
+ cooldownUntil: p.cooldownUntil ?? null,
2196
+ blacklistUntil: p.blacklistUntil ?? null
2197
+ }))
2198
+ .filter((p) => p.providerKey);
2199
+ list.sort((a, b) => a.providerKey.localeCompare(b.providerKey));
2200
+ const max = 60;
2201
+ const visible = list.slice(0, max);
2202
+ if (!visible.length) {
2203
+ if (quotaBody) quotaBody.appendChild(createErrorRow(5, "No quota providers"));
2204
+ } else if (quotaBody) {
2205
+ for (const p of visible) {
2206
+ const tr = document.createElement("tr");
2207
+ tr.appendChild(createCell("td", p.providerKey, "mono truncate", { title: true }));
2208
+ tr.appendChild(createCell("td", p.inPool ? "true" : "false", p.inPool ? "" : "danger"));
2209
+ tr.appendChild(createCell("td", p.reason || "—", "mono truncate", { title: true }));
2210
+ tr.appendChild(createCell("td", formatTs(p.cooldownUntil), "mono"));
2211
+ tr.appendChild(createCell("td", formatTs(p.blacklistUntil), "mono"));
2212
+ quotaBody.appendChild(tr);
2213
+ }
2214
+ if (list.length > max) {
2215
+ const tr = document.createElement("tr");
2216
+ const td = document.createElement("td");
2217
+ td.colSpan = 5;
2218
+ td.className = "muted";
2219
+ td.textContent = `… ${list.length - max} more hidden`;
2220
+ tr.appendChild(td);
2221
+ quotaBody.appendChild(tr);
2222
+ }
2223
+ }
2224
+
2225
+ const hint = $("controlHint");
2226
+ if (hint) {
2227
+ hint.textContent = `servers: ${servers.length} · quota keys: ${list.length} · updated: ${formatTs(snap.nowMs)}`;
2228
+ }
2229
+ }
2230
+
2231
+ async function refreshControl() {
2232
+ setLog("controlOpLog", "");
2233
+ try {
2234
+ const snap = await apiFetch("/daemon/control/snapshot");
2235
+ UI.controlSnapshot = snap;
2236
+ UI.controlSnapshotUpdatedAt = Date.now();
2237
+ renderControlSnapshot();
2238
+ } catch (e) {
2239
+ if (e && e.status === 401) notifyUnauthorizedOnce("control");
2240
+ setLog("controlOpLog", `Control snapshot failed: ${e.message || e}`);
2241
+ }
2242
+ }
2243
+
2244
+ async function controlMutate(action, payload = {}) {
2245
+ const out = await apiFetch("/daemon/control/mutate", {
2246
+ method: "POST",
2247
+ body: JSON.stringify({ action, ...payload })
2248
+ });
2249
+ return out;
2250
+ }
2251
+
1767
2252
  async function refreshProviders() {
1768
2253
  const body = $("providersTbody");
1769
2254
  body.replaceChildren();
@@ -1976,27 +2461,150 @@
1976
2461
  return null;
1977
2462
  }
1978
2463
 
2464
+ function findCredentialForSelection(providerRaw, aliasRaw) {
2465
+ const provider = textOf(providerRaw || "").trim().toLowerCase();
2466
+ const alias = textOf(aliasRaw || "default").trim().toLowerCase() || "default";
2467
+ const list = Array.isArray(UI.credentials) ? UI.credentials : [];
2468
+ for (const item of list) {
2469
+ const itemProvider = textOf(item && item.provider ? item.provider : "").trim().toLowerCase();
2470
+ const itemAlias = textOf(item && item.alias ? item.alias : "default").trim().toLowerCase() || "default";
2471
+ const kind = textOf(item && item.kind ? item.kind : "").trim().toLowerCase();
2472
+ if (kind !== "oauth") continue;
2473
+ if (itemProvider === provider && itemAlias === alias) {
2474
+ return item;
2475
+ }
2476
+ }
2477
+ return null;
2478
+ }
2479
+
2480
+ function formatCredentialExpiry(cred) {
2481
+ if (!cred || cred.expiresInSec == null) return "—";
2482
+ const sec = Number(cred.expiresInSec);
2483
+ if (!Number.isFinite(sec)) return "—";
2484
+ if (sec <= 0) return "expired";
2485
+ if (sec < 60) return `${sec}s`;
2486
+ const min = Math.round(sec / 60);
2487
+ if (min < 60) return `${min}m`;
2488
+ const hour = Math.round(min / 60);
2489
+ return `${hour}h`;
2490
+ }
2491
+
2492
+ function setOauthMode(modeRaw) {
2493
+ const mode = modeRaw === "auto" ? "auto" : "manual";
2494
+ UI.oauthMode = mode;
2495
+ const manualBtn = $("oauthModeManualBtn");
2496
+ const autoBtn = $("oauthModeAutoBtn");
2497
+ const manualPanel = $("oauthManualPanel");
2498
+ const autoPanel = $("oauthAutoPanel");
2499
+ if (manualBtn) manualBtn.classList.toggle("active", mode === "manual");
2500
+ if (autoBtn) autoBtn.classList.toggle("active", mode === "auto");
2501
+ if (manualPanel) manualPanel.style.display = mode === "manual" ? "block" : "none";
2502
+ if (autoPanel) autoPanel.style.display = mode === "auto" ? "block" : "none";
2503
+ }
2504
+
1979
2505
  function updateOauthAuthIssueHint() {
1980
- const el = $("oauthAuthIssueHint");
1981
- if (!el) return;
2506
+ const issueHint = $("oauthAuthIssueHint");
2507
+ const verifyCenter = $("oauthVerifyCenter");
2508
+ const verifyUrlEl = $("oauthVerifyUrl");
1982
2509
  const provider = textOf($("oauthProviderSelect").value || "").trim().toLowerCase();
1983
- const alias = textOf($("oauthAuthAliasInput").value || "default").trim().toLowerCase();
2510
+ const alias = textOf($("oauthAuthAliasInput").value || "default").trim().toLowerCase() || "default";
1984
2511
  const issue = findAuthIssueForProviderAlias(provider, alias);
2512
+ const cred = findCredentialForSelection(provider, alias);
2513
+
2514
+ const statusPill = $("oauthSelectionStatusPill");
2515
+ const expiryPill = $("oauthSelectionExpiryPill");
2516
+ const issuePill = $("oauthSelectionIssuePill");
2517
+ if (statusPill) {
2518
+ statusPill.textContent = `status: ${cred ? textOf(cred.status || "—") : "not found"}`;
2519
+ statusPill.className = "pill";
2520
+ const statusRaw = textOf(cred && cred.status ? cred.status : "").trim().toLowerCase();
2521
+ if (statusRaw === "valid") statusPill.classList.add("ok");
2522
+ else if (statusRaw === "expired" || statusRaw === "invalid") statusPill.classList.add("bad");
2523
+ else if (statusRaw) statusPill.classList.add("warn");
2524
+ }
2525
+ if (expiryPill) {
2526
+ expiryPill.textContent = `expires: ${formatCredentialExpiry(cred)}`;
2527
+ expiryPill.className = "pill";
2528
+ const exp = Number(cred && cred.expiresInSec != null ? cred.expiresInSec : NaN);
2529
+ if (Number.isFinite(exp) && exp > 0 && exp < 600) {
2530
+ expiryPill.classList.add("warn");
2531
+ }
2532
+ }
2533
+ if (issuePill) {
2534
+ const hasIssue = Boolean(issue);
2535
+ issuePill.textContent = hasIssue ? "issue: verify required" : "issue: —";
2536
+ issuePill.className = `pill${hasIssue ? " bad" : ""}`;
2537
+ }
2538
+
1985
2539
  if (!issue) {
1986
- el.style.display = "none";
1987
- el.textContent = "";
1988
- return;
2540
+ if (issueHint) {
2541
+ issueHint.style.display = "none";
2542
+ issueHint.textContent = "";
2543
+ }
2544
+ if (verifyCenter) verifyCenter.style.display = "none";
2545
+ if (verifyUrlEl) verifyUrlEl.textContent = "—";
2546
+ return null;
1989
2547
  }
2548
+
1990
2549
  if (issue.kind === "google_account_verification") {
1991
- const url = issue.url ? `\n\nOpen: ${issue.url}` : "";
1992
- el.textContent =
1993
- `⚠ Google requires account verification for alias \"${alias}\".\n` +
1994
- `Complete the browser verification flow, then click Authorize again.${url}`;
1995
- el.style.display = "block";
2550
+ if (issueHint) {
2551
+ issueHint.textContent =
2552
+ `⚠ Google requires account verification for alias "${alias}".\n` +
2553
+ `Use Verify Center to open the exact verify URL in Camoufox, then re-run auth.`;
2554
+ issueHint.style.display = "block";
2555
+ }
2556
+ if (verifyCenter) verifyCenter.style.display = "block";
2557
+ if (verifyUrlEl) verifyUrlEl.textContent = issue.url || "(missing verify url from quota state)";
2558
+ return issue;
2559
+ }
2560
+
2561
+ if (issueHint) {
2562
+ issueHint.style.display = "none";
2563
+ issueHint.textContent = "";
2564
+ }
2565
+ if (verifyCenter) verifyCenter.style.display = "none";
2566
+ if (verifyUrlEl) verifyUrlEl.textContent = "—";
2567
+ return issue;
2568
+ }
2569
+
2570
+ async function openAuthIssueInCamoufox(provider, alias, url) {
2571
+ setLog("credentialOpLog", "");
2572
+ try {
2573
+ await apiFetch("/daemon/oauth/open", {
2574
+ method: "POST",
2575
+ body: JSON.stringify({ provider, alias, url })
2576
+ });
2577
+ setLog("credentialOpLog", `Opened verification URL in Camoufox for ${provider}.${alias}.`);
2578
+ } catch (e) {
2579
+ setLog("credentialOpLog", `Open failed: ${e.message}`);
2580
+ }
2581
+ }
2582
+
2583
+ async function openSelectedVerifyUrl() {
2584
+ const provider = textOf($("oauthProviderSelect").value || "").trim().toLowerCase();
2585
+ const alias = textOf($("oauthAuthAliasInput").value || "default").trim().toLowerCase() || "default";
2586
+ const issue = findAuthIssueForProviderAlias(provider, alias);
2587
+ if (!issue || !issue.url) {
2588
+ setLog("credentialOpLog", "No verify URL available for current provider/alias.");
2589
+ return;
2590
+ }
2591
+ await openAuthIssueInCamoufox(provider, alias, issue.url);
2592
+ }
2593
+
2594
+ async function copySelectedVerifyUrl() {
2595
+ const provider = textOf($("oauthProviderSelect").value || "").trim().toLowerCase();
2596
+ const alias = textOf($("oauthAuthAliasInput").value || "default").trim().toLowerCase() || "default";
2597
+ const issue = findAuthIssueForProviderAlias(provider, alias);
2598
+ if (!issue || !issue.url) {
2599
+ setLog("credentialOpLog", "No verify URL available for current provider/alias.");
1996
2600
  return;
1997
2601
  }
1998
- el.style.display = "none";
1999
- el.textContent = "";
2602
+ try {
2603
+ await navigator.clipboard.writeText(issue.url);
2604
+ setLog("credentialOpLog", "Verify URL copied.");
2605
+ } catch (e) {
2606
+ setLog("credentialOpLog", `Copy failed: ${e && e.message ? e.message : String(e)}`);
2607
+ }
2000
2608
  }
2001
2609
 
2002
2610
  function listAntigravityProviderKeysByAlias(aliasRaw) {
@@ -2045,6 +2653,7 @@
2045
2653
  body.replaceChildren();
2046
2654
  try {
2047
2655
  const items = await apiFetch("/daemon/credentials");
2656
+ UI.credentials = Array.isArray(items) ? items : [];
2048
2657
  // Best-effort: load quota provider state so we can surface upstream auth issues (e.g. Google verify required).
2049
2658
  try {
2050
2659
  const quota = await apiFetch("/quota/providers");
@@ -2056,7 +2665,6 @@
2056
2665
  // ignore
2057
2666
  }
2058
2667
 
2059
- updateOauthAuthIssueHint();
2060
2668
  for (const c of items || []) {
2061
2669
  const tr = document.createElement("tr");
2062
2670
  const exp = c.expiresInSec == null ? "—" : `${c.expiresInSec}s`;
@@ -2072,11 +2680,13 @@
2072
2680
  if (issue.url) {
2073
2681
  statusTd.appendChild(document.createTextNode(" "));
2074
2682
  const a = document.createElement("a");
2075
- a.href = issue.url;
2076
- a.target = "_blank";
2077
- a.rel = "noreferrer";
2683
+ a.href = "#";
2078
2684
  a.textContent = "open";
2079
2685
  a.className = "mono";
2686
+ a.addEventListener("click", (ev) => {
2687
+ ev.preventDefault();
2688
+ void openAuthIssueInCamoufox(c.provider, c.alias, issue.url);
2689
+ });
2080
2690
  statusTd.appendChild(a);
2081
2691
  }
2082
2692
  }
@@ -2085,8 +2695,10 @@
2085
2695
  tr.appendChild(createCell("td", c.secretRef || "—", "mono"));
2086
2696
  body.appendChild(tr);
2087
2697
  }
2698
+ updateOauthAuthIssueHint();
2088
2699
  } catch (e) {
2089
2700
  body.appendChild(createErrorRow(6, e && e.message ? e.message : e));
2701
+ updateOauthAuthIssueHint();
2090
2702
  }
2091
2703
  }
2092
2704
 
@@ -2112,37 +2724,45 @@
2112
2724
  }
2113
2725
  }
2114
2726
 
2115
- async function authorizeOauth() {
2116
- setLog("credentialOpLog", "");
2117
- const provider = $("oauthProviderSelect").value;
2118
- const alias = ($("oauthAuthAliasInput").value || "default").trim() || "default";
2119
- updateOauthAuthIssueHint();
2120
- const openBrowser = $("oauthOpenBrowser").checked;
2121
- const forceReauthorize = $("oauthForceReauth").checked;
2122
- try {
2123
- const out = await apiFetch("/daemon/oauth/authorize", {
2124
- method: "POST",
2125
- body: JSON.stringify({ provider, alias, openBrowser, forceReauthorize })
2126
- });
2127
- setLog("credentialOpLog", `OK. tokenFile: ${out.tokenFile || ""}`);
2128
- await refreshCredentials();
2129
- // If OAuth was blocked by Google account verification and the user just completed it,
2130
- // attempt to recover the alias back into the pool.
2131
- if (textOf(provider).trim().toLowerCase() === "antigravity") {
2132
- try {
2133
- await recoverAntigravityAliasIfAuthVerify(alias);
2134
- } catch {}
2135
- try {
2136
- await refreshQuota();
2137
- await refreshCredentials();
2138
- } catch {}
2139
- }
2140
- } catch (e) {
2141
- setLog("credentialOpLog", `Authorize failed: ${e.message}`);
2142
- }
2143
- }
2727
+ async function authorizeOauth(mode = "manual") {
2728
+ const safeMode = mode === "auto" ? "auto" : "manual";
2729
+ setLog("credentialOpLog", "");
2730
+ const provider = $("oauthProviderSelect").value;
2731
+ const alias = ($("oauthAuthAliasInput").value || "default").trim() || "default";
2732
+ updateOauthAuthIssueHint();
2733
+
2734
+ const openBrowser = safeMode === "auto"
2735
+ ? Boolean($("oauthAutoOpenBrowser") && $("oauthAutoOpenBrowser").checked)
2736
+ : Boolean($("oauthManualOpenBrowser") && $("oauthManualOpenBrowser").checked);
2737
+ const forceReauthorize = safeMode === "auto"
2738
+ ? Boolean($("oauthAutoForceReauth") && $("oauthAutoForceReauth").checked)
2739
+ : Boolean($("oauthManualForceReauth") && $("oauthManualForceReauth").checked);
2740
+ const headful = safeMode === "auto"
2741
+ ? Boolean($("oauthAutoHeadful") && $("oauthAutoHeadful").checked)
2742
+ : Boolean($("oauthManualHeadful") && $("oauthManualHeadful").checked);
2144
2743
 
2145
- const routingEditorState = {
2744
+ try {
2745
+ const out = await apiFetch("/daemon/oauth/authorize", {
2746
+ method: "POST",
2747
+ body: JSON.stringify({ provider, alias, openBrowser, forceReauthorize, mode: safeMode, headful })
2748
+ });
2749
+ setLog("credentialOpLog", `[${safeMode}] OK. tokenFile: ${out.tokenFile || "—"}`);
2750
+ await refreshCredentials();
2751
+ if (textOf(provider).trim().toLowerCase() === "antigravity") {
2752
+ try {
2753
+ await recoverAntigravityAliasIfAuthVerify(alias);
2754
+ } catch {}
2755
+ try {
2756
+ await refreshQuota();
2757
+ await refreshCredentials();
2758
+ } catch {}
2759
+ }
2760
+ } catch (e) {
2761
+ setLog("credentialOpLog", `[${safeMode}] Authorize failed: ${e.message}`);
2762
+ }
2763
+ }
2764
+
2765
+ const routingEditorState = {
2146
2766
  value: {},
2147
2767
  dirty: false
2148
2768
  };
@@ -2799,13 +3419,14 @@
2799
3419
  }
2800
3420
  }
2801
3421
 
2802
- async function refreshQuota() {
2803
- const body = $("quotaTbody");
2804
- body.replaceChildren();
2805
- setLog("quotaOpLog", "");
2806
- try {
2807
- // Load routing targets so we can filter out providers not referenced by routing pools.
2808
- await refreshRoutingTargets();
3422
+ async function refreshQuota() {
3423
+ const body = $("quotaTbody");
3424
+ body.replaceChildren();
3425
+ setLog("quotaOpLog", "");
3426
+ clearQuotaRenderedGroups();
3427
+ try {
3428
+ // Load routing targets so we can filter out providers not referenced by routing pools.
3429
+ await refreshRoutingTargets();
2809
3430
  // Force refresh quota first so UI doesn't show stale pool state.
2810
3431
  try {
2811
3432
  await apiFetch("/daemon/modules/quota/refresh", { method: "POST" });
@@ -2824,23 +3445,119 @@
2824
3445
  const out = await apiFetch("/quota/providers");
2825
3446
  UI.quotaProviders = Array.isArray(out.providers) ? out.providers : [];
2826
3447
  UI.quotaProvidersUpdatedAt = Date.now();
2827
- UI.quotaProviderMap = new Map(UI.quotaProviders.map((q) => [textOf(q.providerKey), q]));
2828
- updateOauthAuthIssueHint();
2829
- renderQuotaProviders();
2830
- } catch (e) {
2831
- if (e && e.status === 401) notifyUnauthorizedOnce("quota");
2832
- else toast(e && e.message ? e.message : String(e || "quota refresh failed"));
2833
- body.appendChild(createErrorRow(8, e && e.message ? e.message : e));
3448
+ UI.quotaProviderMap = new Map(UI.quotaProviders.map((q) => [textOf(q.providerKey), q]));
3449
+ updateOauthAuthIssueHint();
3450
+ renderQuotaProviders();
3451
+ updateQuotaSelectionSummary();
3452
+ } catch (e) {
3453
+ if (e && e.status === 401) notifyUnauthorizedOnce("quota");
3454
+ else toast(e && e.message ? e.message : String(e || "quota refresh failed"));
3455
+ body.appendChild(createErrorRow(8, e && e.message ? e.message : e));
3456
+ }
3457
+ }
3458
+
3459
+ function normalizeProviderKeyList(value) {
3460
+ if (Array.isArray(value)) {
3461
+ return value.map((k) => textOf(k)).map((k) => k.trim()).filter(Boolean);
3462
+ }
3463
+ const single = textOf(value || "").trim();
3464
+ return single ? [single] : [];
2834
3465
  }
2835
- }
2836
3466
 
2837
- function renderQuotaProviders() {
2838
- const body = $("quotaTbody");
2839
- body.replaceChildren();
3467
+ function clearQuotaRenderedGroups() {
3468
+ UI.quotaRenderedVisibleKeys = [];
3469
+ UI.quotaRenderedGroups = new Map();
3470
+ }
2840
3471
 
2841
- const filter = textOf($("quotaFilterInput").value || "").trim().toLowerCase();
2842
- const hideOk = Boolean($("quotaHideOkToggle").checked);
2843
- const onlyRoutedTargets = Boolean($("quotaOnlyRoutedTargetsToggle").checked);
3472
+ function computeSelectionState(keys) {
3473
+ const sel = UI.quotaSelection instanceof Set ? UI.quotaSelection : new Set();
3474
+ let any = false;
3475
+ let all = true;
3476
+ for (const k of keys) {
3477
+ const hit = sel.has(k);
3478
+ if (hit) any = true;
3479
+ else all = false;
3480
+ }
3481
+ return { any, all };
3482
+ }
3483
+
3484
+ function setGroupCheckboxState(checkbox, keys) {
3485
+ if (!checkbox) return;
3486
+ const { any, all } = computeSelectionState(keys);
3487
+ checkbox.indeterminate = Boolean(any && !all);
3488
+ checkbox.checked = Boolean(all && keys.length > 0);
3489
+ }
3490
+
3491
+ function syncQuotaRowCheckboxes() {
3492
+ const sel = UI.quotaSelection instanceof Set ? UI.quotaSelection : new Set();
3493
+ const body = $("quotaTbody");
3494
+ if (!body) return;
3495
+ body.querySelectorAll("input[data-select-key]").forEach((el) => {
3496
+ const key = el.getAttribute("data-select-key");
3497
+ if (!key) return;
3498
+ el.checked = sel.has(key);
3499
+ });
3500
+ }
3501
+
3502
+ function updateQuotaSelectionSummary() {
3503
+ syncQuotaRowCheckboxes();
3504
+ const hint = $("quotaSelectionHint");
3505
+ if (hint) {
3506
+ const n = UI.quotaSelection instanceof Set ? UI.quotaSelection.size : 0;
3507
+ hint.textContent = `selected ${n}`;
3508
+ }
3509
+ const master = $("quotaSelectAllVisibleToggle");
3510
+ if (master) {
3511
+ setGroupCheckboxState(master, Array.isArray(UI.quotaRenderedVisibleKeys) ? UI.quotaRenderedVisibleKeys : []);
3512
+ }
3513
+ if (UI.quotaRenderedGroups instanceof Map) {
3514
+ for (const entry of UI.quotaRenderedGroups.values()) {
3515
+ if (!entry || !entry.checkbox || !Array.isArray(entry.keys)) continue;
3516
+ setGroupCheckboxState(entry.checkbox, entry.keys);
3517
+ }
3518
+ }
3519
+ }
3520
+
3521
+ function setQuotaSelection(keys, selected) {
3522
+ if (!(UI.quotaSelection instanceof Set)) UI.quotaSelection = new Set();
3523
+ for (const k of keys) {
3524
+ if (!k) continue;
3525
+ if (selected) UI.quotaSelection.add(k);
3526
+ else UI.quotaSelection.delete(k);
3527
+ }
3528
+ updateQuotaSelectionSummary();
3529
+ }
3530
+
3531
+ function clearQuotaSelection() {
3532
+ if (UI.quotaSelection instanceof Set) UI.quotaSelection.clear();
3533
+ updateQuotaSelectionSummary();
3534
+ }
3535
+
3536
+ function resolveQuotaActionTargets() {
3537
+ const selected = UI.quotaSelection instanceof Set ? Array.from(UI.quotaSelection) : [];
3538
+ if (selected.length) return selected;
3539
+ return normalizeProviderKeyList($("quotaKeyInput")?.value);
3540
+ }
3541
+
3542
+ let quotaRefreshTimer = null;
3543
+ function scheduleQuotaRefresh() {
3544
+ const auto = $("quotaAutoRefreshToggle");
3545
+ if (auto && auto.checked === false) return;
3546
+ if (quotaRefreshTimer) clearTimeout(quotaRefreshTimer);
3547
+ quotaRefreshTimer = setTimeout(() => {
3548
+ quotaRefreshTimer = null;
3549
+ void refreshQuota();
3550
+ }, 550);
3551
+ }
3552
+
3553
+ function renderQuotaProviders() {
3554
+ const body = $("quotaTbody");
3555
+ body.replaceChildren();
3556
+ clearQuotaRenderedGroups();
3557
+
3558
+ const filter = textOf($("quotaFilterInput").value || "").trim().toLowerCase();
3559
+ const hideOk = Boolean($("quotaHideOkToggle").checked);
3560
+ const onlyRoutedTargets = Boolean($("quotaOnlyRoutedTargetsToggle").checked);
2844
3561
  const routedProviderKeys = onlyRoutedTargets ? resolveRoutedProviderKeys(UI.routingTargets, UI.quotaProviders) : null;
2845
3562
 
2846
3563
  const list = Array.isArray(UI.quotaProviders) ? UI.quotaProviders : [];
@@ -2854,20 +3571,13 @@
2854
3571
  })
2855
3572
  .slice();
2856
3573
 
2857
- next.sort((a, b) => {
2858
- const aIn = a && a.inPool === true ? 1 : 0;
2859
- const bIn = b && b.inPool === true ? 1 : 0;
2860
- if (aIn !== bIn) return aIn - bIn; // inPool=false first
2861
- const aUntil = Math.max(Number(a?.blacklistUntil || 0), Number(a?.cooldownUntil || 0));
2862
- const bUntil = Math.max(Number(b?.blacklistUntil || 0), Number(b?.cooldownUntil || 0));
2863
- if (aUntil !== bUntil) return bUntil - aUntil;
2864
- return textOf(a?.providerKey).localeCompare(textOf(b?.providerKey));
2865
- });
3574
+ // Keep order stable across polling refreshes: avoid health-based sorting which makes the table jump.
3575
+ next.sort((a, b) => textOf(a?.providerKey).localeCompare(textOf(b?.providerKey)));
2866
3576
 
2867
- if (!next.length) {
2868
- body.appendChild(createErrorRow(8, "No providers matched filter."));
2869
- return;
2870
- }
3577
+ if (!next.length) {
3578
+ body.appendChild(createErrorRow(8, "No providers matched filter."));
3579
+ return;
3580
+ }
2871
3581
 
2872
3582
  function splitProviderKey(providerKey) {
2873
3583
  const raw = textOf(providerKey || "");
@@ -2891,52 +3601,86 @@
2891
3601
  byAlias.get(authAlias).push({ q, pk, providerId, authAlias, model });
2892
3602
  }
2893
3603
 
2894
- const providerIds = Array.from(byProvider.keys()).sort((a, b) => a.localeCompare(b));
2895
- const groupCols = 8;
2896
-
2897
- const appendGroupRow = (label, indent = false) => {
2898
- const tr = document.createElement("tr");
2899
- tr.className = "group-row";
2900
- const td = document.createElement("td");
2901
- td.colSpan = groupCols;
2902
- td.textContent = label;
2903
- if (indent) td.className = "indent";
2904
- tr.appendChild(td);
2905
- body.appendChild(tr);
2906
- };
2907
-
2908
- for (const providerId of providerIds) {
2909
- const byAlias = byProvider.get(providerId);
2910
- let modelsCount = 0;
2911
- for (const v of byAlias.values()) modelsCount += v.length;
2912
- appendGroupRow(`${providerId} (${modelsCount})`);
2913
-
2914
- const aliases = Array.from(byAlias.keys()).sort((a, b) => a.localeCompare(b));
2915
- for (const alias of aliases) {
2916
- const items = byAlias.get(alias);
3604
+ const providerIds = Array.from(byProvider.keys()).sort((a, b) => a.localeCompare(b));
3605
+ const groupCols = 8;
3606
+
3607
+ const appendGroupRow = (label, opts = {}) => {
3608
+ const tr = document.createElement("tr");
3609
+ tr.className = "group-row";
3610
+ const keys = Array.isArray(opts.keys) ? opts.keys : [];
3611
+ const canSelect = Boolean(keys.length);
3612
+ const td0 = document.createElement("td");
3613
+ td0.className = "check-cell";
3614
+ if (canSelect) {
3615
+ const cb = document.createElement("input");
3616
+ cb.type = "checkbox";
3617
+ cb.title = "Select group";
3618
+ cb.addEventListener("change", () => setQuotaSelection(keys, cb.checked));
3619
+ td0.appendChild(cb);
3620
+ if (UI.quotaRenderedGroups instanceof Map && typeof opts.groupId === "string" && opts.groupId) {
3621
+ UI.quotaRenderedGroups.set(opts.groupId, { checkbox: cb, keys });
3622
+ }
3623
+ setGroupCheckboxState(cb, keys);
3624
+ }
3625
+ tr.appendChild(td0);
3626
+
3627
+ const td = document.createElement("td");
3628
+ td.colSpan = groupCols - 1;
3629
+ td.textContent = label;
3630
+ if (opts.indent) td.className = "indent";
3631
+ tr.appendChild(td);
3632
+ body.appendChild(tr);
3633
+ };
3634
+
3635
+ for (const providerId of providerIds) {
3636
+ const byAlias = byProvider.get(providerId);
3637
+ let modelsCount = 0;
3638
+ for (const v of byAlias.values()) modelsCount += v.length;
3639
+ const providerKeys = Array.from(byAlias.values()).flatMap((items) => items.map((it) => it.pk));
3640
+ appendGroupRow(`${providerId} (${modelsCount})`, { keys: providerKeys, groupId: `provider:${providerId}` });
3641
+
3642
+ const aliases = Array.from(byAlias.keys()).sort((a, b) => a.localeCompare(b));
3643
+ for (const alias of aliases) {
3644
+ const items = byAlias.get(alias);
2917
3645
  const fpSuffix = (() => {
2918
3646
  if (providerId !== "antigravity") return "";
2919
3647
  if (!items || !items.length) return "";
2920
3648
  const q0 = items[0] && items[0].q ? items[0].q : null;
2921
3649
  const suffix = textOf(q0 && (q0.fpSuffix || q0.fingerprintSuffix) ? (q0.fpSuffix || q0.fingerprintSuffix) : "");
2922
3650
  return suffix;
2923
- })();
2924
- const fpTag = fpSuffix ? ` · fp=${fpSuffix}` : "";
2925
- appendGroupRow(`${alias || "(no-key)"} (${items.length})${fpTag}`, true);
2926
-
2927
- for (const item of items) {
2928
- const q = item.q;
2929
- const tr = document.createElement("tr");
2930
- tr.className = "provider-row";
2931
- tr.setAttribute("data-provider-key", item.pk);
2932
-
2933
- tr.appendChild(createCell("td", "", "mono"));
2934
- tr.appendChild(createCell("td", "", "mono"));
2935
- tr.appendChild(createCell("td", item.model || item.pk, "mono indent", { title: true }));
2936
-
2937
- const inPool = Boolean(q.inPool);
2938
- const inTd = document.createElement("td");
2939
- inTd.appendChild(pill(inPool ? "true" : "false", inPool ? "ok" : "bad"));
3651
+ })();
3652
+ const fpTag = fpSuffix ? ` · fp=${fpSuffix}` : "";
3653
+ const aliasKeys = Array.isArray(items) ? items.map((it) => it.pk) : [];
3654
+ appendGroupRow(`${alias || "(no-key)"} (${items.length})${fpTag}`, {
3655
+ indent: true,
3656
+ keys: aliasKeys,
3657
+ groupId: `alias:${providerId}.${alias || "(no-key)"}`
3658
+ });
3659
+
3660
+ for (const item of items) {
3661
+ const q = item.q;
3662
+ const tr = document.createElement("tr");
3663
+ tr.className = "provider-row";
3664
+ tr.setAttribute("data-provider-key", item.pk);
3665
+
3666
+ const selTd = document.createElement("td");
3667
+ selTd.className = "check-cell";
3668
+ const cb = document.createElement("input");
3669
+ cb.type = "checkbox";
3670
+ cb.setAttribute("data-select-key", item.pk);
3671
+ cb.checked = UI.quotaSelection instanceof Set ? UI.quotaSelection.has(item.pk) : false;
3672
+ cb.addEventListener("change", () => {
3673
+ setQuotaSelection([item.pk], cb.checked);
3674
+ });
3675
+ selTd.appendChild(cb);
3676
+ tr.appendChild(selTd);
3677
+ tr.appendChild(createCell("td", "", "mono"));
3678
+ tr.appendChild(createCell("td", item.model || item.pk, "mono indent", { title: true }));
3679
+ UI.quotaRenderedVisibleKeys.push(item.pk);
3680
+
3681
+ const inPool = Boolean(q.inPool);
3682
+ const inTd = document.createElement("td");
3683
+ inTd.appendChild(pill(inPool ? "true" : "false", inPool ? "ok" : "bad"));
2940
3684
  tr.appendChild(inTd);
2941
3685
 
2942
3686
  const reason = textOf(q.reason || "");
@@ -2993,11 +3737,12 @@
2993
3737
  actionsTd.appendChild(box);
2994
3738
  tr.appendChild(actionsTd);
2995
3739
 
2996
- body.appendChild(tr);
2997
- }
2998
- }
2999
- }
3000
- }
3740
+ body.appendChild(tr);
3741
+ }
3742
+ }
3743
+ }
3744
+ updateQuotaSelectionSummary();
3745
+ }
3001
3746
 
3002
3747
  function formatRemainingFraction(v) {
3003
3748
  if (typeof v !== "number" || !Number.isFinite(v)) return "—";
@@ -3097,6 +3842,166 @@
3097
3842
  return `${label}: requests=${req} (err=${err}) tokens in/out/total=${inTok}/${outTok}/${totTok}`;
3098
3843
  }
3099
3844
 
3845
+ function summarizeStatsRows(rows) {
3846
+ const byProvider = new Map();
3847
+ const byRuntime = new Map();
3848
+ const byModel = new Map();
3849
+ let totalReq = 0;
3850
+ let totalErr = 0;
3851
+ for (const r of rows) {
3852
+ if (!r || !r.providerKey) continue;
3853
+ const providerKey = textOf(r.providerKey);
3854
+ const providerId = (providerKey.split(".")[0] || providerKey).trim() || providerKey;
3855
+ const runtime = (() => {
3856
+ const parts = providerKey.split(".").filter(Boolean);
3857
+ if (parts.length >= 2) return `${parts[0]}.${parts[1]}`;
3858
+ return providerKey;
3859
+ })();
3860
+ const model = textOf(r.model || "");
3861
+ const req = typeof r.requestCount === "number" ? r.requestCount : 0;
3862
+ const err = typeof r.errorCount === "number" ? r.errorCount : 0;
3863
+ totalReq += req;
3864
+ totalErr += err;
3865
+ const p = byProvider.get(providerId) || { providerId, requestCount: 0, errorCount: 0 };
3866
+ p.requestCount += req;
3867
+ p.errorCount += err;
3868
+ byProvider.set(providerId, p);
3869
+ const rt = byRuntime.get(runtime) || { runtime, requestCount: 0, errorCount: 0 };
3870
+ rt.requestCount += req;
3871
+ rt.errorCount += err;
3872
+ byRuntime.set(runtime, rt);
3873
+ const mKey = model || "—";
3874
+ const m = byModel.get(mKey) || { model: mKey, requestCount: 0, errorCount: 0 };
3875
+ m.requestCount += req;
3876
+ m.errorCount += err;
3877
+ byModel.set(mKey, m);
3878
+ }
3879
+ return {
3880
+ totalReq,
3881
+ totalErr,
3882
+ byProvider: Array.from(byProvider.values()).sort((a, b) => b.requestCount - a.requestCount),
3883
+ byRuntime: Array.from(byRuntime.values()).sort((a, b) => b.requestCount - a.requestCount),
3884
+ byModel: Array.from(byModel.values()).sort((a, b) => b.requestCount - a.requestCount)
3885
+ };
3886
+ }
3887
+
3888
+ function renderStatsTable(bodyEl, rows, colCount, emptyMsg) {
3889
+ bodyEl.replaceChildren();
3890
+ if (!rows.length) {
3891
+ bodyEl.appendChild(createInfoRow(colCount, emptyMsg));
3892
+ return;
3893
+ }
3894
+ for (const r of rows) {
3895
+ const tr = document.createElement("tr");
3896
+ for (const cell of r) {
3897
+ tr.appendChild(createCell("td", cell, "mono truncate", { title: true }));
3898
+ }
3899
+ bodyEl.appendChild(tr);
3900
+ }
3901
+ }
3902
+
3903
+ async function refreshStats() {
3904
+ $("statsSessionTotalsBox").textContent = "";
3905
+ $("statsHistoricalTotalsBox").textContent = "";
3906
+ const sessionProvidersBody = $("statsSessionProvidersTbody");
3907
+ const sessionRuntimesBody = $("statsSessionRuntimesTbody");
3908
+ const sessionModelsBody = $("statsSessionModelsTbody");
3909
+ const sessionErrorsBody = $("statsSessionErrorsTbody");
3910
+ const histProvidersBody = $("statsHistoricalProvidersTbody");
3911
+ const histRuntimesBody = $("statsHistoricalRuntimesTbody");
3912
+ const histModelsBody = $("statsHistoricalModelsTbody");
3913
+ const histErrorsBody = $("statsHistoricalErrorsTbody");
3914
+ sessionProvidersBody.replaceChildren();
3915
+ sessionRuntimesBody.replaceChildren();
3916
+ sessionModelsBody.replaceChildren();
3917
+ sessionErrorsBody.replaceChildren();
3918
+ histProvidersBody.replaceChildren();
3919
+ histRuntimesBody.replaceChildren();
3920
+ histModelsBody.replaceChildren();
3921
+ histErrorsBody.replaceChildren();
3922
+
3923
+ try {
3924
+ const out = await apiFetch("/daemon/stats");
3925
+ const session = out && out.session ? out.session : null;
3926
+ const historical = out && out.historical ? out.historical : null;
3927
+ const sessionRows = session && Array.isArray(session.totals) ? session.totals : [];
3928
+ const histRows = historical && Array.isArray(historical.totals) ? historical.totals : [];
3929
+
3930
+ const s = summarizeStatsRows(sessionRows);
3931
+ const h = summarizeStatsRows(histRows);
3932
+
3933
+ $("statsSessionTotalsBox").textContent = `ALL (session): requests=${formatInt(s.totalReq)} (err=${formatInt(s.totalErr)})`;
3934
+ $("statsHistoricalTotalsBox").textContent = `ALL (historical): requests=${formatInt(h.totalReq)} (err=${formatInt(h.totalErr)})`;
3935
+
3936
+ renderStatsTable(
3937
+ sessionProvidersBody,
3938
+ s.byProvider.map((p) => [p.providerId, formatInt(p.requestCount), formatInt(p.errorCount)]),
3939
+ 3,
3940
+ "No provider stats recorded in this session."
3941
+ );
3942
+ renderStatsTable(
3943
+ sessionRuntimesBody,
3944
+ s.byRuntime.map((r) => [r.runtime, formatInt(r.requestCount), formatInt(r.errorCount)]),
3945
+ 3,
3946
+ "No runtime stats recorded in this session."
3947
+ );
3948
+ renderStatsTable(
3949
+ sessionModelsBody,
3950
+ s.byModel.map((m) => [m.model, formatInt(m.requestCount), formatInt(m.errorCount)]),
3951
+ 3,
3952
+ "No model stats recorded in this session."
3953
+ );
3954
+
3955
+ renderStatsTable(
3956
+ histProvidersBody,
3957
+ h.byProvider.map((p) => [p.providerId, formatInt(p.requestCount), formatInt(p.errorCount)]),
3958
+ 3,
3959
+ "No historical stats recorded."
3960
+ );
3961
+ renderStatsTable(
3962
+ histRuntimesBody,
3963
+ h.byRuntime.map((r) => [r.runtime, formatInt(r.requestCount), formatInt(r.errorCount)]),
3964
+ 3,
3965
+ "No historical stats recorded."
3966
+ );
3967
+ renderStatsTable(
3968
+ histModelsBody,
3969
+ h.byModel.map((m) => [m.model, formatInt(m.requestCount), formatInt(m.errorCount)]),
3970
+ 3,
3971
+ "No historical stats recorded."
3972
+ );
3973
+
3974
+ const toProviderKeys = (rows) =>
3975
+ rows
3976
+ .filter((r) => r && r.providerKey)
3977
+ .map((r) => ({
3978
+ providerKey: textOf(r.providerKey),
3979
+ model: textOf(r.model || "—") || "—",
3980
+ req: typeof r.requestCount === "number" ? r.requestCount : 0,
3981
+ err: typeof r.errorCount === "number" ? r.errorCount : 0
3982
+ }))
3983
+ .sort((a, b) => b.req - a.req || b.err - a.err || a.providerKey.localeCompare(b.providerKey))
3984
+ .slice(0, 50)
3985
+ .map((x) => [x.providerKey, x.model, formatInt(x.req), formatInt(x.err)]);
3986
+
3987
+ renderStatsTable(sessionErrorsBody, toProviderKeys(sessionRows), 4, "No provider stats recorded in this session.");
3988
+ renderStatsTable(histErrorsBody, toProviderKeys(histRows), 4, "No historical provider stats recorded.");
3989
+
3990
+ $("statsLastUpdated").textContent = `updated ${formatEpochMs(Date.now())}`;
3991
+ } catch (e) {
3992
+ if (e && e.status === 401) notifyUnauthorizedOnce("stats");
3993
+ const msg = e && e.message ? e.message : e;
3994
+ renderStatsTable(sessionProvidersBody, [], 3, textOf(msg));
3995
+ renderStatsTable(sessionRuntimesBody, [], 3, textOf(msg));
3996
+ renderStatsTable(sessionModelsBody, [], 3, textOf(msg));
3997
+ renderStatsTable(sessionErrorsBody, [], 4, textOf(msg));
3998
+ renderStatsTable(histProvidersBody, [], 3, textOf(msg));
3999
+ renderStatsTable(histRuntimesBody, [], 3, textOf(msg));
4000
+ renderStatsTable(histModelsBody, [], 3, textOf(msg));
4001
+ renderStatsTable(histErrorsBody, [], 4, textOf(msg));
4002
+ }
4003
+ }
4004
+
3100
4005
  async function refreshTokens() {
3101
4006
  const body = $("tokensTbody");
3102
4007
  body.replaceChildren();
@@ -3198,47 +4103,59 @@
3198
4103
  }
3199
4104
  }
3200
4105
 
3201
- async function quotaAction(kind, providerKey) {
3202
- setLog("quotaOpLog", "");
3203
- if (!providerKey) {
3204
- setLog("quotaOpLog", "providerKey required");
3205
- toast("providerKey required");
3206
- return;
3207
- }
3208
- try {
3209
- if (kind === "recover") {
3210
- await apiFetch(`/quota/providers/${encodeURIComponent(providerKey)}/recover`, { method: "POST" });
3211
- await refreshQuota();
3212
- toast("Recovered.", "ok");
3213
- return;
3214
- }
3215
- if (kind === "reset") {
3216
- await apiFetch(`/quota/providers/${encodeURIComponent(providerKey)}/reset`, { method: "POST" });
3217
- await refreshQuota();
3218
- toast("Reset.", "ok");
3219
- return;
3220
- }
3221
- if (kind === "disable") {
3222
- const minutes = Number.parseFloat(textOf($("quotaDurationSelect").value || "60"));
3223
- if (!Number.isFinite(minutes) || minutes <= 0) {
3224
- throw new Error("Invalid minutes");
4106
+ async function quotaAction(kind, providerKeys) {
4107
+ setLog("quotaOpLog", "");
4108
+ const keys = normalizeProviderKeyList(providerKeys);
4109
+ if (!keys.length) {
4110
+ setLog("quotaOpLog", "providerKey required (or select rows)");
4111
+ toast("providerKey required (or select rows)");
4112
+ return;
4113
+ }
4114
+ try {
4115
+ const many = keys.length > 1;
4116
+ if (many) {
4117
+ const ok = confirm(`${kind} on ${keys.length} providerKeys?`);
4118
+ if (!ok) return;
3225
4119
  }
3226
- const modeRaw = (textOf($("quotaModeSelect").value) || "cooldown").trim().toLowerCase();
3227
- const mode = modeRaw === "blacklist" ? "blacklist" : "cooldown";
3228
- await apiFetch(`/quota/providers/${encodeURIComponent(providerKey)}/disable`, {
3229
- method: "POST",
3230
- body: JSON.stringify({ mode, durationMinutes: minutes })
3231
- });
3232
- await refreshQuota();
3233
- toast("Applied.", "ok");
3234
- return;
3235
- }
3236
- } catch (e) {
3237
- if (e && e.status === 401) notifyUnauthorizedOnce("quota action");
3238
- setLog("quotaOpLog", `Action failed: ${e && e.message ? e.message : e}`);
3239
- toast(`Action failed: ${e && e.message ? e.message : String(e)}`);
3240
- }
3241
- }
4120
+ if (kind === "recover") {
4121
+ for (const key of keys) {
4122
+ await apiFetch(`/quota/providers/${encodeURIComponent(key)}/recover`, { method: "POST" });
4123
+ }
4124
+ scheduleQuotaRefresh();
4125
+ toast(many ? `Recovered (${keys.length}).` : "Recovered.", "ok");
4126
+ return;
4127
+ }
4128
+ if (kind === "reset") {
4129
+ for (const key of keys) {
4130
+ await apiFetch(`/quota/providers/${encodeURIComponent(key)}/reset`, { method: "POST" });
4131
+ }
4132
+ scheduleQuotaRefresh();
4133
+ toast(many ? `Reset (${keys.length}).` : "Reset.", "ok");
4134
+ return;
4135
+ }
4136
+ if (kind === "disable") {
4137
+ const minutes = Number.parseFloat(textOf($("quotaDurationSelect").value || "60"));
4138
+ if (!Number.isFinite(minutes) || minutes <= 0) {
4139
+ throw new Error("Invalid minutes");
4140
+ }
4141
+ const modeRaw = (textOf($("quotaModeSelect").value) || "cooldown").trim().toLowerCase();
4142
+ const mode = modeRaw === "blacklist" ? "blacklist" : "cooldown";
4143
+ for (const key of keys) {
4144
+ await apiFetch(`/quota/providers/${encodeURIComponent(key)}/disable`, {
4145
+ method: "POST",
4146
+ body: JSON.stringify({ mode, durationMinutes: minutes })
4147
+ });
4148
+ }
4149
+ scheduleQuotaRefresh();
4150
+ toast(many ? `Applied (${keys.length}).` : "Applied.", "ok");
4151
+ return;
4152
+ }
4153
+ } catch (e) {
4154
+ if (e && e.status === 401) notifyUnauthorizedOnce("quota action");
4155
+ setLog("quotaOpLog", `Action failed: ${e && e.message ? e.message : e}`);
4156
+ toast(`Action failed: ${e && e.message ? e.message : String(e)}`);
4157
+ }
4158
+ }
3242
4159
 
3243
4160
  async function resetQuotaModule() {
3244
4161
  setLog("quotaOpLog", "");
@@ -3410,7 +4327,73 @@
3410
4327
  }
3411
4328
  });
3412
4329
 
4330
+ $("controlRefreshBtn").addEventListener("click", refreshControl);
4331
+ $("controlRestartAllBtn").addEventListener("click", async () => {
4332
+ setLog("controlOpLog", "");
4333
+ if (!confirm("Broadcast restart all local RouteCodex servers now?")) return;
4334
+ try {
4335
+ const out = await controlMutate("servers.restart", {});
4336
+ setLog("controlOpLog", `Restart requested.\n${JSON.stringify(out, null, 2)}`);
4337
+ await refreshControl();
4338
+ await refreshStatus();
4339
+ } catch (e) {
4340
+ setLog("controlOpLog", `Restart failed: ${e.message || e}`);
4341
+ }
4342
+ });
4343
+ $("controlQuotaRefreshBtn").addEventListener("click", async () => {
4344
+ setLog("controlOpLog", "");
4345
+ try {
4346
+ const out = await controlMutate("quota.refresh", {});
4347
+ setLog("controlOpLog", `Quota refresh requested.\n${JSON.stringify(out, null, 2)}`);
4348
+ await refreshControl();
4349
+ } catch (e) {
4350
+ setLog("controlOpLog", `Quota refresh failed: ${e.message || e}`);
4351
+ }
4352
+ });
4353
+ $("controlQuotaOfflineBtn").addEventListener("click", async () => {
4354
+ setLog("controlOpLog", "");
4355
+ const providerKey = ($("controlQuotaKeyInput").value || "").trim();
4356
+ if (!providerKey) return toast("providerKey required");
4357
+ const mode = $("controlQuotaModeSelect").value === "blacklist" ? "blacklist" : "cooldown";
4358
+ const minutes = Number($("controlQuotaDurationSelect").value || 0);
4359
+ const durationMs = Number.isFinite(minutes) && minutes > 0 ? minutes * 60 * 1000 : 0;
4360
+ if (!durationMs) return toast("duration required");
4361
+ try {
4362
+ const out = await controlMutate("quota.disable", { providerKey, mode, durationMs });
4363
+ setLog("controlOpLog", `Offline requested.\n${JSON.stringify(out, null, 2)}`);
4364
+ await refreshControl();
4365
+ } catch (e) {
4366
+ setLog("controlOpLog", `Offline failed: ${e.message || e}`);
4367
+ }
4368
+ });
4369
+ $("controlQuotaRecoverBtn").addEventListener("click", async () => {
4370
+ setLog("controlOpLog", "");
4371
+ const providerKey = ($("controlQuotaKeyInput").value || "").trim();
4372
+ if (!providerKey) return toast("providerKey required");
4373
+ try {
4374
+ const out = await controlMutate("quota.recover", { providerKey });
4375
+ setLog("controlOpLog", `Recover requested.\n${JSON.stringify(out, null, 2)}`);
4376
+ await refreshControl();
4377
+ } catch (e) {
4378
+ setLog("controlOpLog", `Recover failed: ${e.message || e}`);
4379
+ }
4380
+ });
4381
+ $("controlQuotaResetBtn").addEventListener("click", async () => {
4382
+ setLog("controlOpLog", "");
4383
+ const providerKey = ($("controlQuotaKeyInput").value || "").trim();
4384
+ if (!providerKey) return toast("providerKey required");
4385
+ try {
4386
+ const out = await controlMutate("quota.reset", { providerKey });
4387
+ setLog("controlOpLog", `Reset requested.\n${JSON.stringify(out, null, 2)}`);
4388
+ await refreshControl();
4389
+ } catch (e) {
4390
+ setLog("controlOpLog", `Reset failed: ${e.message || e}`);
4391
+ }
4392
+ });
4393
+
3413
4394
  $("refreshProvidersBtn").addEventListener("click", refreshProviders);
4395
+ $("refreshStatsBtn").addEventListener("click", refreshStats);
4396
+ $("statsAutoRefresh").addEventListener("change", syncStatsAutoRefresh);
3414
4397
  $("refreshTokensBtn").addEventListener("click", refreshTokens);
3415
4398
  $("newProviderBtn").addEventListener("click", () => {
3416
4399
  $("providerEditorTitle").textContent = "Provider editor (new)";
@@ -3472,33 +4455,58 @@
3472
4455
 
3473
4456
  $("refreshCredentialsBtn").addEventListener("click", refreshCredentials);
3474
4457
  $("saveOauthBrowserBtn").addEventListener("click", saveSettings);
3475
- $("oauthAuthorizeBtn").addEventListener("click", authorizeOauth);
4458
+ $("oauthModeManualBtn").addEventListener("click", () => setOauthMode("manual"));
4459
+ $("oauthModeAutoBtn").addEventListener("click", () => setOauthMode("auto"));
4460
+ $("oauthAuthorizeManualBtn").addEventListener("click", () => void authorizeOauth("manual"));
4461
+ $("oauthAuthorizeAutoBtn").addEventListener("click", () => void authorizeOauth("auto"));
4462
+ $("oauthVerifyOpenBtn").addEventListener("click", () => void openSelectedVerifyUrl());
4463
+ $("oauthVerifyCopyBtn").addEventListener("click", () => void copySelectedVerifyUrl());
4464
+ $("oauthVerifyRecheckBtn").addEventListener("click", async () => {
4465
+ try {
4466
+ await refreshQuota();
4467
+ await refreshCredentials();
4468
+ setLog("credentialOpLog", "Rechecked quota/credential state.");
4469
+ } catch (e) {
4470
+ setLog("credentialOpLog", `Recheck failed: ${e && e.message ? e.message : String(e)}`);
4471
+ }
4472
+ });
3476
4473
  $("oauthProviderSelect").addEventListener("change", updateOauthAuthIssueHint);
3477
4474
  $("oauthAuthAliasInput").addEventListener("input", updateOauthAuthIssueHint);
3478
-
3479
- $("refreshQuotaBtn").addEventListener("click", refreshQuota);
3480
- $("refreshQuotaSnapshotBtn").addEventListener("click", refreshQuotaSnapshotNow);
3481
- $("quotaFilterInput").addEventListener("input", renderQuotaProviders);
3482
- $("quotaHideOkToggle").addEventListener("change", renderQuotaProviders);
3483
- $("quotaOnlyRoutedTargetsToggle").addEventListener("change", renderQuotaProviders);
3484
- $("quotaApplyDisableBtn").addEventListener("click", () => void quotaAction("disable", $("quotaKeyInput").value));
3485
- $("quotaApplyRecoverBtn").addEventListener("click", () => void quotaAction("recover", $("quotaKeyInput").value));
3486
- $("quotaApplyResetBtn").addEventListener("click", () => void quotaAction("reset", $("quotaKeyInput").value));
3487
- $("quotaTbody").addEventListener("click", (ev) => {
3488
- const tr = ev.target.closest("tr");
3489
- if (tr && tr.getAttribute) {
3490
- const pk = tr.getAttribute("data-provider-key");
4475
+ setOauthMode("manual");
4476
+
4477
+ $("refreshQuotaBtn").addEventListener("click", refreshQuota);
4478
+ $("refreshQuotaSnapshotBtn").addEventListener("click", refreshQuotaSnapshotNow);
4479
+ $("quotaFilterInput").addEventListener("input", renderQuotaProviders);
4480
+ $("quotaHideOkToggle").addEventListener("change", renderQuotaProviders);
4481
+ $("quotaOnlyRoutedTargetsToggle").addEventListener("change", renderQuotaProviders);
4482
+ $("quotaSelectVisibleBtn").addEventListener("click", () => {
4483
+ const keys = Array.isArray(UI.quotaRenderedVisibleKeys) ? UI.quotaRenderedVisibleKeys : [];
4484
+ setQuotaSelection(keys, true);
4485
+ });
4486
+ $("quotaClearSelectionBtn").addEventListener("click", clearQuotaSelection);
4487
+ $("quotaSelectAllVisibleToggle").addEventListener("change", () => {
4488
+ const master = $("quotaSelectAllVisibleToggle");
4489
+ const keys = Array.isArray(UI.quotaRenderedVisibleKeys) ? UI.quotaRenderedVisibleKeys : [];
4490
+ setQuotaSelection(keys, Boolean(master && master.checked));
4491
+ });
4492
+ $("quotaApplyDisableBtn").addEventListener("click", () => void quotaAction("disable", resolveQuotaActionTargets()));
4493
+ $("quotaApplyRecoverBtn").addEventListener("click", () => void quotaAction("recover", resolveQuotaActionTargets()));
4494
+ $("quotaApplyResetBtn").addEventListener("click", () => void quotaAction("reset", resolveQuotaActionTargets()));
4495
+ $("quotaTbody").addEventListener("click", (ev) => {
4496
+ const tr = ev.target.closest("tr");
4497
+ if (tr && tr.getAttribute) {
4498
+ const pk = tr.getAttribute("data-provider-key");
3491
4499
  if (pk) $("quotaKeyInput").value = pk;
3492
4500
  }
3493
4501
  const el = ev.target.closest("button");
3494
4502
  if (!el) return;
3495
- const action = el.getAttribute("data-action");
3496
- const key = el.getAttribute("data-key");
3497
- if (key) $("quotaKeyInput").value = key;
3498
- if (action === "quota-recover") void quotaAction("recover", key);
3499
- else if (action === "quota-reset") void quotaAction("reset", key);
3500
- else if (action === "quota-disable") void quotaAction("disable", key);
3501
- });
4503
+ const action = el.getAttribute("data-action");
4504
+ const key = el.getAttribute("data-key");
4505
+ if (key) $("quotaKeyInput").value = key;
4506
+ if (action === "quota-recover") void quotaAction("recover", [key]);
4507
+ else if (action === "quota-reset") void quotaAction("reset", [key]);
4508
+ else if (action === "quota-disable") void quotaAction("disable", [key]);
4509
+ });
3502
4510
  $("resetQuotaBtn").addEventListener("click", resetQuotaModule);
3503
4511
 
3504
4512
  $("loadRoutingBtn").addEventListener("click", loadRouting);