feat: add provider quota visibility settings
This commit is contained in:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,6 +13,7 @@ const DEFAULT_SETTINGS = {
|
||||
tailscaleUrl: "",
|
||||
stickyRoundRobinLimit: 3,
|
||||
providerStrategies: {},
|
||||
quotaVisibility: {},
|
||||
comboStrategy: "fallback",
|
||||
comboStickyRoundRobinLimit: 1,
|
||||
comboStrategies: {},
|
||||
|
||||
55
tests/unit/provider-quota-visibility.test.js
Normal file
55
tests/unit/provider-quota-visibility.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user