From aa00828055263c958d86cc26cc7132a5b45ddda5 Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 18:48:38 +0900 Subject: [PATCH 1/4] feat(gui): redesigned OAuth accounts workspace (carry #5408) OAuth account lists become a card grid (or compact rows) with a toolbar for search, quota filters, sorting, title mode and view mode, pool availability and token estimates, and per-family Gemini/Claude limits for Antigravity. Each card keeps pause/resume, switch, alias, reauth, Grok coupons, Claude reset grants, the Kiro skip reason and the Anthropic per-account threshold. Removal asks in a dialog bound to the provider and server it was opened for. Antigravity gets a Cockpit Tools import card and an auth-choice modal, and the provider rail can collapse. A per-account refresh forces only that account upstream (accountId on GET /api/oauth/accounts; an unknown id forces all). The pools hook keeps the latest quota outcome with its generation, so a superseded refresh succeeds only when a newer outcome succeeded. The list rereads cached quota every 30s while visible; it does not force upstream probes, and the mount-time forced refresh is gone. Quota rosters keep four workers. Carried from #5408 (source head 2db983800a02785250cc3ebed86f399bc772303e). Co-authored-by: agentHits <140916359+agentHits@users.noreply.github.com> --- .../src/content/docs/guides/providers.md | 2 +- .../src/content/docs/guides/web-dashboard.md | 2 +- .../content/docs/reference/management-api.md | 2 +- gui/public/provider-icons/cockpit-tools.png | Bin 0 -> 18255 bytes gui/src/App.tsx | 4 +- gui/src/components/AccountAuthChoiceModal.tsx | 123 ++ gui/src/components/AntigravityChoiceModal.tsx | 18 + .../provider-workspace/CockpitToolsCard.tsx | 298 +++ .../ProviderAccountCard.tsx | 544 ++++++ .../ProviderAccountsToolbar.tsx | 849 +++++++++ .../provider-workspace/ProviderAuthPanel.tsx | 512 +++-- .../provider-workspace/ProviderDetails.tsx | 32 +- .../provider-workspace/ProviderDialogs.tsx | 55 +- .../ProviderWorkspaceShell.tsx | 54 +- .../account-quota-analysis.ts | 412 +++++ .../account-tokens-estimate.ts | 551 ++++++ .../components/provider-workspace/types.ts | 14 +- gui/src/hooks/useProviderAccountPools.ts | 86 +- gui/src/i18n/de.ts | 78 + gui/src/i18n/en.ts | 78 + gui/src/i18n/fr.ts | 78 + gui/src/i18n/ja.ts | 78 + gui/src/i18n/ko.ts | 78 + gui/src/i18n/pt.ts | 78 + gui/src/i18n/ru.ts | 78 + gui/src/i18n/tr.ts | 78 + gui/src/i18n/vi.ts | 78 + gui/src/i18n/zh-TW.ts | 78 + gui/src/i18n/zh.ts | 78 + gui/src/icons.tsx | 1 + gui/src/pages/Providers.tsx | 51 +- gui/src/pages/providers-page-modals.tsx | 21 + gui/src/styles/provider-catalog.css | 176 ++ .../styles/provider-workspace-settings.css | 1644 +++++++++++++++++ gui/src/styles/provider-workspace-shell.css | 144 ++ .../anthropic2-provider-surfaces.test.tsx | 20 +- gui/tests/fr-localization.test.ts | 18 + gui/tests/locale-parity.test.ts | 12 + gui/tests/provider-account-import.test.tsx | 143 +- .../provider-account-quota-loading.test.tsx | 118 +- .../provider-quota-refresh-controls.test.tsx | 36 + scripts/test-layout/layout.json | 2 +- src/providers/quota.ts | 7 +- src/server/management/oauth-account-routes.ts | 6 +- structure/transports/inventory.md | 2 + tests/fixtures/test-layout-expected.json | 3 +- tests/gui/gui-account-quota-analysis.test.ts | 242 +++ .../providers/provider-account-quota.test.ts | 39 + 48 files changed, 6739 insertions(+), 362 deletions(-) create mode 100644 gui/public/provider-icons/cockpit-tools.png create mode 100644 gui/src/components/AccountAuthChoiceModal.tsx create mode 100644 gui/src/components/AntigravityChoiceModal.tsx create mode 100644 gui/src/components/provider-workspace/CockpitToolsCard.tsx create mode 100644 gui/src/components/provider-workspace/ProviderAccountCard.tsx create mode 100644 gui/src/components/provider-workspace/ProviderAccountsToolbar.tsx create mode 100644 gui/src/components/provider-workspace/account-quota-analysis.ts create mode 100644 gui/src/components/provider-workspace/account-tokens-estimate.ts create mode 100644 tests/gui/gui-account-quota-analysis.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 520161b511b..42b3538b610 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -382,7 +382,7 @@ Tokens stay in `~/.opencodex/auth.json`; `/api/oauth/accounts` returns masked me ### Cockpit Tools Antigravity import -For v1, OpenCodex imports only a **Cockpit Tools Antigravity** JSON export for the `google-antigravity` provider. In the Providers dashboard, choose the local JSON file from that provider's Accounts tab. The dashboard does not show the file contents or credential values; it reports only imported, updated, failed, and unsupported counts. Other Cockpit providers are rejected in v1. +For v1, OpenCodex imports only a **Cockpit Tools Antigravity** JSON export for the `google-antigravity` provider. In the Providers dashboard, open that provider's Accounts tab and choose **Log in** or **Add account**: the dialog offers browser login or a Cockpit Tools import from a local JSON file, the clipboard, or pasted text. The dashboard does not show the file contents or credential values; it reports only imported, updated, failed, and unsupported counts. Other Cockpit providers are rejected in v1. The CLI accepts the export from a file or standard input only — never paste it into a command argument: diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 403cea49705..1a1bd3e9684 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -117,7 +117,7 @@ is visible) and never forces an upstream refresh. | **Startup safety** | Show whether injected Codex routing survives a restart, with separate service and launcher-shim health plus exact repair commands. | | **Windows tray** | Install a per-user login tray for one-click proxy start, stop, restart, dashboard access, and status. The tray is a controller, not a proxy restart service. | | **Codex autostart** | Allow an already-installed Codex launcher shim to run `ocx ensure`. This toggle does not install a shim or background service. | -| **Providers** | Add, edit, set the default (enabled providers only), enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Removing the current default switches to the first remaining enabled provider when one exists; otherwise deletion is refused and the current default is kept. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. For Claude (Anthropic) OAuth pools, each logged-in account shows its own 5-hour and weekly rate-limit bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. Other OAuth providers with a logged-in account (Google Antigravity, xAI, Kiro, Cursor and the rest) show an **Account pool** card. Its switch controls the pre-dispatch account preference; a 429 still fails over either way. The strategy is marked **Saved, not live** and cannot be changed there until `pool.kernel` is on, and a strategy set only from the CLI, such as Kiro's `least-loaded`, is shown by name and kept when you toggle the pool. The Provider Overview shown when no provider is selected carries a **Refresh all quotas** control that forces one server-side re-read of every configured provider; a provider whose upstream probe fails keeps its last-good row, so the status line reports that the check completed rather than claiming every value is fresh, and each row's own age stays the per-provider freshness signal. | +| **Providers** | Add, edit, set the default (enabled providers only), enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Removing the current default switches to the first remaining enabled provider when one exists; otherwise deletion is refused and the current default is kept. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. OAuth account lists show each account as a card (or a compact row) with search, quota filters, sorting, a login/hidden/alias title switch, and a per-account quota refresh that re-probes only that account; removing an account asks for confirmation in a dialog. The list rereads cached quota every 30 seconds while the dashboard is visible. For Claude (Anthropic) OAuth pools, each logged-in account shows its own 5-hour and weekly rate-limit bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. Other OAuth providers with a logged-in account (Google Antigravity, xAI, Kiro, Cursor and the rest) show an **Account pool** card. Its switch controls the pre-dispatch account preference; a 429 still fails over either way. The strategy is marked **Saved, not live** and cannot be changed there until `pool.kernel` is on, and a strategy set only from the CLI, such as Kiro's `least-loaded`, is shown by name and kept when you toggle the pool. The Provider Overview shown when no provider is selected carries a **Refresh all quotas** control that forces one server-side re-read of every configured provider; a provider whose upstream probe fails keeps its last-good row, so the status line reports that the check completed rather than claiming every value is fresh, and each row's own age stays the per-provider freshness signal. | | **Add provider** | One search above the tabs reaches all four at once — Accounts, Free, Local, Paid. While a query is live the results are grouped by tab with a count each, and the selected tab stays put rather than jumping. Press ArrowDown in the search box to focus the first available result action, skipping disabled buttons; if no action is available, focus stays in the search box. Local runtimes (Ollama, vLLM, LM Studio, LiteLLM) have their own tab, and a long provider note clamps to two lines with the full text one click away. Claude Code CLI (subscription) sits under Paid with a Subscription CLI badge, and selecting it warns that it bills the signed-in Claude subscription through `claude -p` and never uses an API key (use `anthropic-apikey` for key billing). | | **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, enable or disable quota auto-switch, set its 1–100% threshold, and configure transient-failure failover. | | **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. | diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 19c55968fa8..e9edc722ca5 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -593,7 +593,7 @@ Labels are read on login/import or refresh, never inferred from quota percentage | `POST /api/oauth/login/cancel` | Cancel a public in-progress OAuth flow | 400 unknown provider | | `GET /api/oauth/status` | Poll one provider's OAuth flow | 400 unknown provider | | `POST /api/oauth/logout` | Remove the selected provider credential | 400 unknown provider; `oauth_mutation_busy` | -| `GET /api/oauth/accounts` | List masked accounts; Anthropic and generic OAuth account rows include their `paused` state. Kiro rows include `autoSelectable` and a closed `skipReason` when excluded from automatic selection; an active singleton may still send. Quota remains opt-in. | 400 invalid provider | +| `GET /api/oauth/accounts` | List masked accounts; Anthropic and generic OAuth account rows include their `paused` state. Kiro rows include `autoSelectable` and a closed `skipReason` when excluded from automatic selection; an active singleton may still send. Quota remains opt-in (`quota=1`); `refresh=1` forces upstream probes, and adding `accountId=` forces only that account while the rest are served from cache. An id that matches no account forces every account. | 400 invalid provider | | `DELETE /api/oauth/accounts` | Remove one account | 400 invalid provider/id; 404 account missing; `oauth_mutation_busy` | | `PUT /api/oauth/accounts/active` | Select the active OAuth account | 400 invalid provider/account; 404 account missing; 409 account paused; `oauth_mutation_busy` | | `PUT /api/oauth/accounts/pause` | Pause or resume one Anthropic or generic OAuth account. Body `{ provider, accountId, paused }`; pausing the active account selects the next usable account when available. Pause is durable and independent of pool enablement; resume preserves health and credentials. | 400 unsupported provider or invalid body; 404 account missing; `oauth_mutation_busy` | diff --git a/gui/public/provider-icons/cockpit-tools.png b/gui/public/provider-icons/cockpit-tools.png new file mode 100644 index 0000000000000000000000000000000000000000..729eaaae8667a8f34dffbfb6875788e31da44ea7 GIT binary patch literal 18255 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 San=+70000 - {t(tkey)} + {t(tkey)} ); diff --git a/gui/src/components/AccountAuthChoiceModal.tsx b/gui/src/components/AccountAuthChoiceModal.tsx new file mode 100644 index 00000000000..3e1aea13ffd --- /dev/null +++ b/gui/src/components/AccountAuthChoiceModal.tsx @@ -0,0 +1,123 @@ +import { useEffect, useRef } from "react"; +import { IconX } from "../icons"; +import { useT } from "../i18n/shared"; +import { ProviderIcon } from "./provider-workspace/ProviderRail"; +import CockpitToolsCard from "./provider-workspace/CockpitToolsCard"; + +export interface AccountAuthChoiceModalProps { + provider: string; + providerLabel: string; + apiBase: string; + isOpen: boolean; + isBusy?: boolean; + onClose: () => void; + onContinueOAuth: () => void; + onImportSuccess?: () => void; +} + +export default function AccountAuthChoiceModal({ + provider, + providerLabel, + apiBase, + isOpen, + isBusy = false, + onClose, + onContinueOAuth, + onImportSuccess, +}: AccountAuthChoiceModalProps) { + const t = useT(); + const dialog = useRef(null); + const primary = useRef(null); + useEffect(() => { + if (!isOpen) return; + const previous = document.activeElement as HTMLElement | null; + primary.current?.focus(); + // Deferred: React is still removing the dialog during cleanup and resets focus to the + // body afterwards, which would discard a synchronous restoration. + return () => { requestAnimationFrame(() => { if (previous?.isConnected && typeof previous.focus === "function") previous.focus(); }); }; + }, [isOpen]); + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
{ + if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose(); } + if (event.key !== "Tab") return; + const focusables = [...(dialog.current?.querySelectorAll("button:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])") ?? [])].filter(el => el.offsetParent !== null); + const buttons = focusables as HTMLButtonElement[]; + const first = buttons?.[0], last = buttons?.[buttons.length - 1]; + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } + }} + > +
e.stopPropagation()}> +
+
+

+ {t("pws.authChoiceModalTitle", { provider: providerLabel })} +

+

+ {t("pws.authChoiceModalSubtitle")} +

+
+ +
+ +
+ {/* Method 1: Browser OAuth */} +
+
+ +
+ {t("pws.antigravityOauthTitle")} + {t("pws.antigravityOauthDesc")} +
+
+ +
+ + {/* Method 2: Cockpit Tools Card */} + +
+
+
+ ); +} diff --git a/gui/src/components/AntigravityChoiceModal.tsx b/gui/src/components/AntigravityChoiceModal.tsx new file mode 100644 index 00000000000..f8ce7796a3b --- /dev/null +++ b/gui/src/components/AntigravityChoiceModal.tsx @@ -0,0 +1,18 @@ +import { useT } from "../i18n/shared"; +import AccountAuthChoiceModal, { type AccountAuthChoiceModalProps } from "./AccountAuthChoiceModal"; + +export type AntigravityChoiceModalProps = Omit & { + provider?: string; + providerLabel?: string; +}; + +export default function AntigravityChoiceModal(props: AntigravityChoiceModalProps) { + const t = useT(); + return ( + + ); +} diff --git a/gui/src/components/provider-workspace/CockpitToolsCard.tsx b/gui/src/components/provider-workspace/CockpitToolsCard.tsx new file mode 100644 index 00000000000..d28b825642f --- /dev/null +++ b/gui/src/components/provider-workspace/CockpitToolsCard.tsx @@ -0,0 +1,298 @@ +import { useRef, useState } from "react"; +import { useT } from "../../i18n/shared"; +import { IconClipboard } from "../../icons"; + +const COCKPIT_IMPORT_MAX_BYTES = 256 * 1024; + +const COCKPIT_RESULT_KEYS = new Set([ + "totalCount", + "importedCount", + "updatedCount", + "failedCount", + "unsupportedCount", + "results", +]); + +const COCKPIT_RESULT_STATUSES = new Set(["imported", "updated", "failed", "unsupported"]); +const COCKPIT_STATUS_CODES: Record> = { + imported: new Set(["imported"]), + updated: new Set(["updated"]), + failed: new Set(["invalid_record", "credential_rejected", "identity_mismatch", "missing_project", "persist_failed"]), + unsupported: new Set(["unsupported_provider", "unsupported_format"]), +}; + +function isPlainObject(value: unknown): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +export type CockpitImportResult = { + totalCount: number; + importedCount: number; + updatedCount: number; + failedCount: number; + unsupportedCount: number; + results: Array<{ index: number; status: string; code: string }>; +}; + +function safeCockpitImportResult(value: unknown): CockpitImportResult | null { + if (!isPlainObject(value) || Object.keys(value).some(key => !COCKPIT_RESULT_KEYS.has(key))) return null; + const { totalCount, importedCount, updatedCount, failedCount, unsupportedCount, results } = value; + if ( + typeof totalCount !== "number" || typeof importedCount !== "number" + || typeof updatedCount !== "number" || typeof failedCount !== "number" + || typeof unsupportedCount !== "number" || !Array.isArray(results) + ) return null; + for (const [index, result] of results.entries()) { + if (!isPlainObject(result) || Object.keys(result).some(key => !["index", "status", "code"].includes(key))) return null; + const status = String(result.status); + const code = String(result.code); + if (result.index !== index || !COCKPIT_RESULT_STATUSES.has(status)) return null; + const allowedCodes = COCKPIT_STATUS_CODES[status]; + if (!allowedCodes || !allowedCodes.has(code)) return null; + } + return { + totalCount, importedCount, updatedCount, failedCount, unsupportedCount, + results: results as Array<{ index: number; status: string; code: string }>, + }; +} + +export interface CockpitToolsCardProps { + apiBase: string; + onImportSuccess?: () => void | Promise; + className?: string; +} + +export default function CockpitToolsCard({ + apiBase, + onImportSuccess, + className = "", +}: CockpitToolsCardProps) { + const t = useT(); + const [importBusy, setImportBusy] = useState(false); + const [importStatus, setImportStatus] = useState<"idle" | "invalid" | "failed" | "complete">("idle"); + const [importResult, setImportResult] = useState(null); + const [showManualPaste, setShowManualPaste] = useState(false); + const [manualPasteText, setManualPasteText] = useState(""); + const importFileRef = useRef(null); + + const importCockpitJson = async (rawText: string) => { + const text = rawText.trim(); + if (!text || importBusy) return; + setImportBusy(true); + setImportStatus("idle"); + setImportResult(null); + try { + if (new TextEncoder().encode(text).byteLength > COCKPIT_IMPORT_MAX_BYTES) { + setImportStatus("invalid"); + return; + } + let document: unknown; + try { + document = JSON.parse(text) as unknown; + } catch { + // No lenient fallback: a context-free trailing-comma strip also rewrites + // text inside JSON strings and can submit corrupted account data. + setImportStatus("invalid"); + return; + } + if (isPlainObject(document)) { + document = [document]; + } + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 25000); + let response: Response; + try { + response = await fetch(`${apiBase}/api/oauth/accounts/import`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "google-antigravity", format: "cockpit-tools", document }), + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + if (!response.ok) { + setImportStatus("failed"); + return; + } + const result = safeCockpitImportResult(await response.json().catch(() => null)); + if (!result) { + setImportStatus("failed"); + return; + } + setImportResult(result); + setImportStatus("complete"); + setShowManualPaste(false); + setManualPasteText(""); + try { + await onImportSuccess?.(); + } catch { + /* Preserve the completed import state */ + } + } catch { + setImportStatus("failed"); + } finally { + if (importFileRef.current) importFileRef.current.value = ""; + setImportBusy(false); + } + }; + + const importCockpitFile = async (file: File | undefined) => { + if (!file || importBusy) return; + if (!file.name.toLowerCase().endsWith(".json") || file.size > COCKPIT_IMPORT_MAX_BYTES) { + setImportStatus("invalid"); + setImportResult(null); + if (importFileRef.current) importFileRef.current.value = ""; + return; + } + try { + const text = await file.text(); + await importCockpitJson(text); + } catch { + setImportStatus("failed"); + setImportResult(null); + } finally { + if (importFileRef.current) importFileRef.current.value = ""; + } + }; + + const importCockpitClipboard = async () => { + if (importBusy) return; + setImportStatus("idle"); + setImportResult(null); + try { + if (typeof navigator !== "undefined" && navigator.clipboard && typeof navigator.clipboard.readText === "function") { + const text = await navigator.clipboard.readText(); + if (text && text.trim()) { + await importCockpitJson(text); + return; + } + } + } catch { + setShowManualPaste(true); + return; + } + setShowManualPaste(true); + }; + + return ( +
+
+
+ +
+ {t("pws.cockpitCardTitle")} + {t("pws.cockpitCardSubtitle")} +
+
+
+ + { void importCockpitFile(event.currentTarget.files?.[0]); }} + /> + + + +
+
+ {showManualPaste && ( +
+ +