From 4dadab9d5fdde8e670ab8571d5e8239ce7b3697b Mon Sep 17 00:00:00 2001 From: nguyenha935 <208228297+nguyenha935@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:26:49 +0700 Subject: [PATCH] feat: add provider quota visibility settings --- .../components/ProviderLimits/QuotaTable.js | 20 +++- .../usage/components/ProviderLimits/index.js | 91 ++++++++++++++++++- .../usage/components/ProviderLimits/utils.js | 24 +++++ src/lib/db/repos/settingsRepo.js | 1 + tests/unit/provider-quota-visibility.test.js | 55 +++++++++++ 5 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 tests/unit/provider-quota-visibility.test.js diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js index 8f2a1bc3..299445ef 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js @@ -89,6 +89,7 @@ export default function QuotaTable({ compact = false, sortMode = "default", showSortLabel = false, + onHideQuota = null, }) { const [page, setPage] = useState(1); @@ -132,6 +133,7 @@ export default function QuotaTable({ const resetPrimary = compact ? "text-[11px]" : "text-sm"; const resetSecondary = compact ? "text-[10px] leading-tight" : "text-xs"; const sortLabel = "Sorted by account remaining"; + const hasHideAction = typeof onHideQuota === "function"; return (
@@ -195,7 +197,7 @@ export default function QuotaTable({
- + {countdown !== "-" || resetDisplay ? ( compact ? (
N/A
)} + + {hasHideAction && ( + + + + )} ); })} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index 95300c92..babfa6e2 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -8,6 +8,9 @@ import Tooltip from "@/shared/components/Tooltip"; import { parseQuotaData, calculatePercentage, + filterQuotasByVisibility, + getHiddenQuotaRows, + getQuotaVisibilityKey, getConnectionLabel, getConnectionQuotaRemaining, sortVisibleConnections, @@ -146,6 +149,7 @@ export default function ProviderLimits() { const [providerOptions, setProviderOptions] = useState([]); const [accountFilter, setAccountFilter] = useState("all"); const [quotaSortMode, setQuotaSortMode] = useState("default"); + const [quotaVisibility, setQuotaVisibility] = useState({}); const [expiringFirst, setExpiringFirst] = useState(false); const [providerMenuOpen, setProviderMenuOpen] = useState(false); const [bulkToggling, setBulkToggling] = useState(false); @@ -536,10 +540,13 @@ export default function ProviderLimits() { useEffect(() => { fetch("/api/settings", { cache: "no-store" }) .then((r) => (r.ok ? r.json() : {})) - .then((s) => setAutoPingMaps({ - claude: s?.claudeAutoPing?.connections || {}, - codex: s?.codexAutoPing?.connections || {}, - })) + .then((s) => { + setAutoPingMaps({ + claude: s?.claudeAutoPing?.connections || {}, + codex: s?.codexAutoPing?.connections || {}, + }); + setQuotaVisibility(s?.quotaVisibility || {}); + }) .catch(() => {}); }, []); @@ -565,6 +572,57 @@ export default function ProviderLimits() { } }, [autoPingMaps]); + const updateQuotaVisibility = useCallback(async (nextVisibility, previousVisibility) => { + setQuotaVisibility(nextVisibility); + try { + const response = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ quotaVisibility: nextVisibility }), + }); + if (!response.ok) throw new Error("Failed to update quota visibility"); + } catch (error) { + console.error("Error updating quota visibility:", error); + setQuotaVisibility(previousVisibility); + } + }, []); + + const handleHideQuota = useCallback((provider, quota) => { + const key = getQuotaVisibilityKey(quota); + if (!provider || !key) return; + + const previous = quotaVisibility; + const providerVisibility = previous[provider] || {}; + const hidden = new Set(providerVisibility.hidden || []); + hidden.add(key); + const next = { + ...previous, + [provider]: { + ...providerVisibility, + hidden: [...hidden], + }, + }; + updateQuotaVisibility(next, previous); + }, [quotaVisibility, updateQuotaVisibility]); + + const handleShowQuota = useCallback((provider, quota) => { + const key = getQuotaVisibilityKey(quota); + if (!provider || !key) return; + + const previous = quotaVisibility; + const providerVisibility = previous[provider] || {}; + const hidden = new Set(providerVisibility.hidden || []); + hidden.delete(key); + const next = { + ...previous, + [provider]: { + ...providerVisibility, + hidden: [...hidden], + }, + }; + updateQuotaVisibility(next, previous); + }, [quotaVisibility, updateQuotaVisibility]); + // Auto-refresh interval useEffect(() => { if (!hasHydratedAutoRefresh || !autoRefresh) { @@ -973,6 +1031,9 @@ export default function ProviderLimits() { const resetCreditCount = getCodexResetCreditCount(quota); const isResettingLimit = resettingLimitId === conn.id; const rowBusy = deletingId === conn.id || togglingId === conn.id || isResettingLimit; + const rawQuotas = quota?.quotas || []; + const visibleQuotas = filterQuotasByVisibility(conn.provider, rawQuotas, quotaVisibility); + const hiddenQuotaRows = getHiddenQuotaRows(conn.provider, rawQuotas, quotaVisibility); return ( ) : ( handleHideQuota(conn.provider, quotaRow)} /> )} + {hiddenQuotaRows.length > 0 && ( +
+ + visibility_off + + Hidden: + {hiddenQuotaRows.map((quotaRow) => ( + + ))} +
+ )}
); diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index 688f0ab7..19fdaa8e 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -300,6 +300,30 @@ export function getRemainingPercentage(quota) { return calculatePercentage(quota?.used, quota?.total); } +export function getQuotaVisibilityKey(quota) { + if (!quota || typeof quota !== "object") return ""; + return String(quota.modelKey || quota.name || "").trim(); +} + +function getProviderHiddenQuotaSet(provider, quotaVisibility) { + const hidden = quotaVisibility?.[provider]?.hidden; + return new Set(Array.isArray(hidden) ? hidden.map(String) : []); +} + +export function filterQuotasByVisibility(provider, quotas = [], quotaVisibility = {}) { + if (!Array.isArray(quotas) || quotas.length === 0) return []; + const hidden = getProviderHiddenQuotaSet(provider, quotaVisibility); + if (hidden.size === 0) return quotas; + return quotas.filter((quota) => !hidden.has(getQuotaVisibilityKey(quota))); +} + +export function getHiddenQuotaRows(provider, quotas = [], quotaVisibility = {}) { + if (!Array.isArray(quotas) || quotas.length === 0) return []; + const hidden = getProviderHiddenQuotaSet(provider, quotaVisibility); + if (hidden.size === 0) return []; + return quotas.filter((quota) => hidden.has(getQuotaVisibilityKey(quota))); +} + /** * Parse provider-specific quota structures into normalized array * @param {string} provider - Provider name (github, antigravity, codex, kiro, claude) diff --git a/src/lib/db/repos/settingsRepo.js b/src/lib/db/repos/settingsRepo.js index 0057cc1c..60a5054b 100644 --- a/src/lib/db/repos/settingsRepo.js +++ b/src/lib/db/repos/settingsRepo.js @@ -13,6 +13,7 @@ const DEFAULT_SETTINGS = { tailscaleUrl: "", stickyRoundRobinLimit: 3, providerStrategies: {}, + quotaVisibility: {}, comboStrategy: "fallback", comboStickyRoundRobinLimit: 1, comboStrategies: {}, diff --git a/tests/unit/provider-quota-visibility.test.js b/tests/unit/provider-quota-visibility.test.js new file mode 100644 index 00000000..d6616b81 --- /dev/null +++ b/tests/unit/provider-quota-visibility.test.js @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + filterQuotasByVisibility, + getHiddenQuotaRows, + parseQuotaData, +} from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js"; + +describe("provider quota visibility", () => { + const data = { + quotas: { + "gemini-pro-agent": { + displayName: "Gemini 3.1 Pro (High)", + used: 200, + total: 1000, + resetAt: "2026-07-04T00:00:00Z", + }, + "claude-opus-4-6-thinking": { + displayName: "Claude Opus 4.6 (Thinking)", + used: 100, + total: 1000, + resetAt: "2026-07-04T00:00:00Z", + }, + }, + }; + + it("keeps Antigravity modelKey so hidden settings use stable quota ids", () => { + const quotas = parseQuotaData("antigravity", data); + expect(quotas.map((q) => q.modelKey)).toEqual([ + "gemini-pro-agent", + "claude-opus-4-6-thinking", + ]); + }); + + it("shows all quotas by default and hides configured provider rows", () => { + const quotas = parseQuotaData("antigravity", data); + expect(filterQuotasByVisibility("antigravity", quotas, {})).toHaveLength(2); + + const visibility = { + antigravity: { hidden: ["claude-opus-4-6-thinking"] }, + }; + const visible = filterQuotasByVisibility("antigravity", quotas, visibility); + const hidden = getHiddenQuotaRows("antigravity", quotas, visibility); + + expect(visible.map((q) => q.modelKey)).toEqual(["gemini-pro-agent"]); + expect(hidden.map((q) => q.modelKey)).toEqual(["claude-opus-4-6-thinking"]); + }); + + it("does not apply one provider hidden list to another provider", () => { + const quotas = parseQuotaData("antigravity", data); + const visibility = { + codex: { hidden: ["gemini-pro-agent"] }, + }; + expect(filterQuotasByVisibility("antigravity", quotas, visibility)).toHaveLength(2); + }); +});