mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
feat: add provider quota visibility settings
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user