feat: add provider quota visibility settings

This commit is contained in:
nguyenha935
2026-07-04 23:26:49 +07:00
parent 7f436e2792
commit 4dadab9d5f
5 changed files with 185 additions and 6 deletions

View File

@@ -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 (
<div className="space-y-2">
@@ -195,7 +197,7 @@ export default function QuotaTable({
</div>
</td>
<td className={`${cellPad} w-[25%]`}>
<td className={`${cellPad} ${hasHideAction ? "w-[20%]" : "w-[25%]"}`}>
{countdown !== "-" || resetDisplay ? (
compact ? (
<div
@@ -222,6 +224,22 @@ export default function QuotaTable({
<div className={`${resetPrimary} text-text-muted italic`}>N/A</div>
)}
</td>
{hasHideAction && (
<td className={`${cellPad} w-[5%] text-right`}>
<button
type="button"
onClick={() => onHideQuota(quota)}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-text-muted transition-colors hover:bg-black/5 hover:text-text-primary dark:hover:bg-white/5"
title="Hide this quota row"
aria-label={`Hide quota ${quota.name}`}
>
<span className="material-symbols-outlined text-[15px]">
visibility_off
</span>
</button>
</td>
)}
</tr>
);
})}

View File

@@ -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 (
<Card
@@ -1194,14 +1255,34 @@ export default function ProviderLimits() {
</div>
) : (
<QuotaTable
quotas={quota?.quotas}
quotas={visibleQuotas}
compact
sortMode="default"
showSortLabel={
conn.provider === "codex" && quotaSortMode !== "default"
}
onHideQuota={(quotaRow) => handleHideQuota(conn.provider, quotaRow)}
/>
)}
{hiddenQuotaRows.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1 border-t border-black/5 pt-2 text-[10px] text-text-muted dark:border-white/5">
<span className="material-symbols-outlined text-[14px]">
visibility_off
</span>
<span>Hidden:</span>
{hiddenQuotaRows.map((quotaRow) => (
<button
key={getQuotaVisibilityKey(quotaRow)}
type="button"
onClick={() => handleShowQuota(conn.provider, quotaRow)}
className="rounded-md border border-black/10 px-1.5 py-0.5 transition-colors hover:bg-black/5 hover:text-text-primary dark:border-white/10 dark:hover:bg-white/5"
title="Show this quota row"
>
{quotaRow.name}
</button>
))}
</div>
)}
</div>
</Card>
);

View File

@@ -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)

View File

@@ -13,6 +13,7 @@ const DEFAULT_SETTINGS = {
tailscaleUrl: "",
stickyRoundRobinLimit: 3,
providerStrategies: {},
quotaVisibility: {},
comboStrategy: "fallback",
comboStickyRoundRobinLimit: 1,
comboStrategies: {},

View File

@@ -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);
});
});