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
@@ -89,6 +89,7 @@ export default function QuotaTable({
compact = false, compact = false,
sortMode = "default", sortMode = "default",
showSortLabel = false, showSortLabel = false,
onHideQuota = null,
}) { }) {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -132,6 +133,7 @@ export default function QuotaTable({
const resetPrimary = compact ? "text-[11px]" : "text-sm"; const resetPrimary = compact ? "text-[11px]" : "text-sm";
const resetSecondary = compact ? "text-[10px] leading-tight" : "text-xs"; const resetSecondary = compact ? "text-[10px] leading-tight" : "text-xs";
const sortLabel = "Sorted by account remaining"; const sortLabel = "Sorted by account remaining";
const hasHideAction = typeof onHideQuota === "function";
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@@ -195,7 +197,7 @@ export default function QuotaTable({
</div> </div>
</td> </td>
<td className={`${cellPad} w-[25%]`}> <td className={`${cellPad} ${hasHideAction ? "w-[20%]" : "w-[25%]"}`}>
{countdown !== "-" || resetDisplay ? ( {countdown !== "-" || resetDisplay ? (
compact ? ( compact ? (
<div <div
@@ -222,6 +224,22 @@ export default function QuotaTable({
<div className={`${resetPrimary} text-text-muted italic`}>N/A</div> <div className={`${resetPrimary} text-text-muted italic`}>N/A</div>
)} )}
</td> </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> </tr>
); );
})} })}
@@ -8,6 +8,9 @@ import Tooltip from "@/shared/components/Tooltip";
import { import {
parseQuotaData, parseQuotaData,
calculatePercentage, calculatePercentage,
filterQuotasByVisibility,
getHiddenQuotaRows,
getQuotaVisibilityKey,
getConnectionLabel, getConnectionLabel,
getConnectionQuotaRemaining, getConnectionQuotaRemaining,
sortVisibleConnections, sortVisibleConnections,
@@ -146,6 +149,7 @@ export default function ProviderLimits() {
const [providerOptions, setProviderOptions] = useState([]); const [providerOptions, setProviderOptions] = useState([]);
const [accountFilter, setAccountFilter] = useState("all"); const [accountFilter, setAccountFilter] = useState("all");
const [quotaSortMode, setQuotaSortMode] = useState("default"); const [quotaSortMode, setQuotaSortMode] = useState("default");
const [quotaVisibility, setQuotaVisibility] = useState({});
const [expiringFirst, setExpiringFirst] = useState(false); const [expiringFirst, setExpiringFirst] = useState(false);
const [providerMenuOpen, setProviderMenuOpen] = useState(false); const [providerMenuOpen, setProviderMenuOpen] = useState(false);
const [bulkToggling, setBulkToggling] = useState(false); const [bulkToggling, setBulkToggling] = useState(false);
@@ -536,10 +540,13 @@ export default function ProviderLimits() {
useEffect(() => { useEffect(() => {
fetch("/api/settings", { cache: "no-store" }) fetch("/api/settings", { cache: "no-store" })
.then((r) => (r.ok ? r.json() : {})) .then((r) => (r.ok ? r.json() : {}))
.then((s) => setAutoPingMaps({ .then((s) => {
setAutoPingMaps({
claude: s?.claudeAutoPing?.connections || {}, claude: s?.claudeAutoPing?.connections || {},
codex: s?.codexAutoPing?.connections || {}, codex: s?.codexAutoPing?.connections || {},
})) });
setQuotaVisibility(s?.quotaVisibility || {});
})
.catch(() => {}); .catch(() => {});
}, []); }, []);
@@ -565,6 +572,57 @@ export default function ProviderLimits() {
} }
}, [autoPingMaps]); }, [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 // Auto-refresh interval
useEffect(() => { useEffect(() => {
if (!hasHydratedAutoRefresh || !autoRefresh) { if (!hasHydratedAutoRefresh || !autoRefresh) {
@@ -973,6 +1031,9 @@ export default function ProviderLimits() {
const resetCreditCount = getCodexResetCreditCount(quota); const resetCreditCount = getCodexResetCreditCount(quota);
const isResettingLimit = resettingLimitId === conn.id; const isResettingLimit = resettingLimitId === conn.id;
const rowBusy = deletingId === conn.id || togglingId === conn.id || isResettingLimit; 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 ( return (
<Card <Card
@@ -1194,14 +1255,34 @@ export default function ProviderLimits() {
</div> </div>
) : ( ) : (
<QuotaTable <QuotaTable
quotas={quota?.quotas} quotas={visibleQuotas}
compact compact
sortMode="default" sortMode="default"
showSortLabel={ showSortLabel={
conn.provider === "codex" && quotaSortMode !== "default" 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> </div>
</Card> </Card>
); );
@@ -300,6 +300,30 @@ export function getRemainingPercentage(quota) {
return calculatePercentage(quota?.used, quota?.total); 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 * Parse provider-specific quota structures into normalized array
* @param {string} provider - Provider name (github, antigravity, codex, kiro, claude) * @param {string} provider - Provider name (github, antigravity, codex, kiro, claude)
+1
View File
@@ -13,6 +13,7 @@ const DEFAULT_SETTINGS = {
tailscaleUrl: "", tailscaleUrl: "",
stickyRoundRobinLimit: 3, stickyRoundRobinLimit: 3,
providerStrategies: {}, providerStrategies: {},
quotaVisibility: {},
comboStrategy: "fallback", comboStrategy: "fallback",
comboStickyRoundRobinLimit: 1, comboStickyRoundRobinLimit: 1,
comboStrategies: {}, comboStrategies: {},
@@ -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);
});
});