mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
feat: update the quota tracking for user role
This commit is contained in:
@@ -32,7 +32,7 @@ const USAGE_HANDLERS = {
|
|||||||
"gemini-cli": (c) => getGeminiUsage(c.accessToken, c.providerDataWithProjectId, c.proxyOptions),
|
"gemini-cli": (c) => getGeminiUsage(c.accessToken, c.providerDataWithProjectId, c.proxyOptions),
|
||||||
antigravity: (c) => getAntigravityUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
antigravity: (c) => getAntigravityUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
||||||
claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions),
|
claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions),
|
||||||
codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions),
|
codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions, c.providerSpecificData),
|
||||||
kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
||||||
qoder: (c) => getQoderUsage(c.accessToken, c.proxyOptions),
|
qoder: (c) => getQoderUsage(c.accessToken, c.proxyOptions),
|
||||||
qwen: (c) => getQwenUsage(c.accessToken, c.providerSpecificData),
|
qwen: (c) => getQwenUsage(c.accessToken, c.providerSpecificData),
|
||||||
|
|||||||
@@ -80,17 +80,26 @@ function getCodexReviewRateLimit(data) {
|
|||||||
}) || null;
|
}) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCodexUsage(accessToken, proxyOptions = null) {
|
export async function getCodexUsage(accessToken, proxyOptions = null, providerSpecificData = null) {
|
||||||
try {
|
try {
|
||||||
const response = await proxyAwareFetch(CODEX_CONFIG.usageUrl, {
|
const accountId = getCodexAccountId(providerSpecificData);
|
||||||
method: "GET",
|
const headers = {
|
||||||
headers: {
|
|
||||||
"Authorization": `Bearer ${accessToken}`,
|
"Authorization": `Bearer ${accessToken}`,
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
},
|
"OpenAI-Beta": "codex-1",
|
||||||
|
"originator": "codex_cli_rs",
|
||||||
|
};
|
||||||
|
if (accountId) headers["ChatGPT-Account-ID"] = accountId;
|
||||||
|
|
||||||
|
const response = await proxyAwareFetch(CODEX_CONFIG.usageUrl, {
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
}, proxyOptions);
|
}, proxyOptions);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
return { message: `Codex authentication failed (${response.status}). Please re-authorize the connection.` };
|
||||||
|
}
|
||||||
return { message: `Codex connected. Usage API temporarily unavailable (${response.status}).` };
|
return { message: `Codex connected. Usage API temporarily unavailable (${response.status}).` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
import { Suspense } from "react";
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, useEffect } from "react";
|
||||||
import { CardSkeleton } from "@/shared/components/Loading";
|
import { CardSkeleton } from "@/shared/components/Loading";
|
||||||
import ProviderLimits from "../usage/components/ProviderLimits";
|
import ProviderLimits from "../usage/components/ProviderLimits";
|
||||||
|
import SystemQuotaOverview from "../usage/components/SystemQuotaOverview";
|
||||||
|
import useUserStore from "@/store/userStore";
|
||||||
|
|
||||||
export default function QuotaPage() {
|
export default function QuotaPage() {
|
||||||
|
const user = useUserStore((state) => state.user);
|
||||||
|
const loading = useUserStore((state) => state.loading);
|
||||||
|
const fetchCurrentUser = useUserStore((state) => state.fetchCurrentUser);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCurrentUser();
|
||||||
|
}, [fetchCurrentUser]);
|
||||||
|
|
||||||
|
if (!user || loading) return <CardSkeleton />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<CardSkeleton />}>
|
<Suspense fallback={<CardSkeleton />}>
|
||||||
<ProviderLimits />
|
{user.role === "admin" ? <ProviderLimits /> : <SystemQuotaOverview />}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export default function QuotaProgressBar({
|
|||||||
unlimited = false,
|
unlimited = false,
|
||||||
resetTime = null,
|
resetTime = null,
|
||||||
recurring = true,
|
recurring = true,
|
||||||
|
showUsageDetails = true,
|
||||||
}) {
|
}) {
|
||||||
const colors = getColorClasses(percentage);
|
const colors = getColorClasses(percentage);
|
||||||
const countdown = formatResetTime(resetTime);
|
const countdown = formatResetTime(resetTime);
|
||||||
@@ -110,11 +111,13 @@ export default function QuotaProgressBar({
|
|||||||
|
|
||||||
{/* Usage details and countdown */}
|
{/* Usage details and countdown */}
|
||||||
<div className="flex items-center justify-between text-xs text-text-muted">
|
<div className="flex items-center justify-between text-xs text-text-muted">
|
||||||
|
{showUsageDetails && (
|
||||||
<span>
|
<span>
|
||||||
{used.toLocaleString()} / {total.toLocaleString()} requests
|
{used.toLocaleString()} / {total.toLocaleString()} requests
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
{countdown !== "-" && (
|
{countdown !== "-" && (
|
||||||
<div className="flex items-center gap-1">
|
<div className={`flex items-center gap-1${showUsageDetails ? "" : " ml-auto"}`}>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span className="font-medium">{resetWord} in {countdown}</span>
|
<span className="font-medium">{resetWord} in {countdown}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||||
|
import { Button, Card, CardSkeleton } from "@/shared/components";
|
||||||
|
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||||
|
import { formatResetTime, REFRESH_INTERVAL_MS } from "./ProviderLimits/utils";
|
||||||
|
|
||||||
|
function getProviderInfo(providerId) {
|
||||||
|
return AI_PROVIDERS[providerId] || {
|
||||||
|
name: providerId,
|
||||||
|
color: "#6b7280",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUpdatedAt(value) {
|
||||||
|
if (!value) return "Not updated yet";
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!Number.isFinite(date.getTime())) return "Not updated yet";
|
||||||
|
|
||||||
|
return `Updated ${date.toLocaleTimeString(undefined, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResetAt(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!Number.isFinite(date.getTime())) return null;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const isToday = date.toDateString() === now.toDateString();
|
||||||
|
const isTomorrow = date.toDateString() === new Date(now.getTime() + 86400000).toDateString();
|
||||||
|
const time = date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||||
|
|
||||||
|
if (isToday) return `today at ${time}`;
|
||||||
|
if (isTomorrow) return `tomorrow at ${time}`;
|
||||||
|
return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaTone(percentage) {
|
||||||
|
if (percentage > 70) {
|
||||||
|
return { bar: "bg-green-500", dot: "bg-green-500", text: "text-green-500" };
|
||||||
|
}
|
||||||
|
if (percentage >= 30) {
|
||||||
|
return { bar: "bg-yellow-500", dot: "bg-yellow-500", text: "text-yellow-500" };
|
||||||
|
}
|
||||||
|
return { bar: "bg-red-500", dot: "bg-red-500", text: "text-red-500" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuotaListRow({ quota }) {
|
||||||
|
const tone = getQuotaTone(quota.remainingPercentage);
|
||||||
|
const resetIn = formatResetTime(quota.resetAt);
|
||||||
|
const resetAt = formatResetAt(quota.resetAt);
|
||||||
|
const resetLabel = quota.recurring === false ? "Expires" : "Resets";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="py-3 first:pt-0 last:pb-0">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="min-w-0 truncate text-sm font-medium text-text-main">{quota.name}</span>
|
||||||
|
<span className={`inline-flex shrink-0 items-center gap-2 text-sm font-semibold tabular-nums ${tone.text}`}>
|
||||||
|
<span className={`h-2 w-2 rounded-full ${tone.dot}`} aria-hidden="true" />
|
||||||
|
{quota.remainingPercentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mt-2 h-1.5 overflow-hidden rounded-full bg-surface-2"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label={`${quota.name} quota remaining`}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={quota.remainingPercentage}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-[width] duration-300 ${tone.bar}`}
|
||||||
|
style={{ width: `${Math.min(Math.max(quota.remainingPercentage, 0), 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(resetIn !== "-" || resetAt) && (
|
||||||
|
<p className="mt-2 text-xs text-text-muted">
|
||||||
|
{resetIn !== "-" && <span>{resetLabel} in {resetIn}</span>}
|
||||||
|
{resetIn !== "-" && resetAt && <span className="px-1.5 text-text-muted/60"> · </span>}
|
||||||
|
{resetAt && <span>{resetAt}</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderQuotaListItem({ provider }) {
|
||||||
|
const providerInfo = getProviderInfo(provider.provider);
|
||||||
|
const providerName = providerInfo.name || provider.provider;
|
||||||
|
const hasQuotaData = provider.quotas.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="flex flex-col gap-5 px-4 py-5 sm:flex-row sm:gap-8 sm:px-6 sm:py-6">
|
||||||
|
<header className="flex shrink-0 items-start justify-between gap-4 sm:w-52 sm:flex-col sm:gap-3 lg:w-60">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl"
|
||||||
|
style={{ backgroundColor: `${providerInfo.color || "#6b7280"}1A` }}
|
||||||
|
>
|
||||||
|
<ProviderIcon
|
||||||
|
src={`/providers/${provider.provider}.png`}
|
||||||
|
alt={`${providerName} logo`}
|
||||||
|
size={30}
|
||||||
|
className="rounded-md"
|
||||||
|
fallbackText={providerName.slice(0, 1).toUpperCase()}
|
||||||
|
fallbackColor={providerInfo.color}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="truncate font-semibold text-text-main">{providerName}</h2>
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
{provider.accountCount} {provider.accountCount === 1 ? "account" : "accounts"} connected
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{hasQuotaData ? (
|
||||||
|
<ul className="min-w-0 flex-1 divide-y divide-border-subtle">
|
||||||
|
{provider.quotas.map((quota) => <QuotaListRow key={quota.name} quota={quota} />)}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 rounded-lg border border-dashed border-border-subtle bg-bg px-4 py-4 text-sm text-text-muted">
|
||||||
|
{provider.failedAccountCount > 0
|
||||||
|
? provider.errorMessage || "Quota data is temporarily unavailable for this provider."
|
||||||
|
: "This provider does not currently report quota data."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{provider.failedAccountCount > 0 && hasQuotaData && (
|
||||||
|
<p className="self-end text-xs text-text-muted sm:max-w-44">
|
||||||
|
Some account quota checks could not be completed.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewSkeleton() {
|
||||||
|
return (
|
||||||
|
<Card padding="none" className="overflow-hidden">
|
||||||
|
{[1, 2, 3].map((key) => (
|
||||||
|
<div key={key} className="border-b border-border-subtle p-4 last:border-b-0 sm:p-6">
|
||||||
|
<CardSkeleton />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SystemQuotaOverview() {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const loadQuota = useCallback(async (forceRefresh = false) => {
|
||||||
|
if (forceRefresh) setRefreshing(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/usage/system-quota${forceRefresh ? "?refresh=true" : ""}`,
|
||||||
|
{ cache: "no-store" },
|
||||||
|
);
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || "Failed to load system quota");
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(payload);
|
||||||
|
} catch (loadError) {
|
||||||
|
setError(loadError.message || "Failed to load system quota");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initialLoadId = window.setTimeout(() => loadQuota(), 0);
|
||||||
|
const intervalId = window.setInterval(() => loadQuota(true), REFRESH_INTERVAL_MS);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(initialLoadId);
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [loadQuota]);
|
||||||
|
|
||||||
|
if (loading) return <OverviewSkeleton />;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col items-center gap-4 py-12 text-center">
|
||||||
|
<span className="material-symbols-outlined text-4xl text-red-500">error</span>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-semibold text-text-main">Unable to load system quota</h1>
|
||||||
|
<p className="mt-1 text-sm text-text-muted">{error}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" icon="refresh" onClick={() => loadQuota(true)}>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = data?.providers || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-5 sm:gap-6">
|
||||||
|
<div className="flex flex-col gap-4 border-b border-border-subtle pb-5 sm:flex-row sm:items-end sm:justify-between sm:pb-6">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<span className="material-symbols-outlined text-brand-500">data_usage</span>
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-brand-500">System quota</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight text-text-main sm:text-2xl">Available provider capacity</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3 sm:justify-end">
|
||||||
|
<span className="text-xs text-text-muted">{formatUpdatedAt(data?.updatedAt)}</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon="refresh"
|
||||||
|
loading={refreshing}
|
||||||
|
onClick={() => loadQuota(true)}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{providers.length === 0 ? (
|
||||||
|
<Card className="flex flex-col items-center gap-3 py-12 text-center">
|
||||||
|
<span className="material-symbols-outlined text-4xl text-text-muted">cloud_off</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-text-main">No quota-capable providers connected</h2>
|
||||||
|
<p className="mt-1 text-sm text-text-muted">
|
||||||
|
System quota will appear when an administrator connects a provider that supports usage tracking.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card padding="none" className="overflow-hidden">
|
||||||
|
{providers.map((provider, index) => (
|
||||||
|
<div key={provider.provider} className={index > 0 ? "border-t border-border-subtle" : ""}>
|
||||||
|
<ProviderQuotaListItem provider={provider} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
// Ensure proxyFetch is loaded to patch globalThis.fetch
|
||||||
|
import "open-sse/index.js";
|
||||||
|
|
||||||
|
import { getProviderConnections } from "@/lib/localDb";
|
||||||
|
import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
||||||
|
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
||||||
|
import { USAGE_APIKEY_PROVIDERS, USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
||||||
|
import { refreshAndUpdateCredentials } from "@/app/api/usage/[connectionId]/route";
|
||||||
|
import { getUsageForProvider } from "open-sse/services/usage.js";
|
||||||
|
import {
|
||||||
|
getRemainingPercentage,
|
||||||
|
parseQuotaData,
|
||||||
|
} from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 60 * 1000;
|
||||||
|
let cachedSystemQuota = null;
|
||||||
|
|
||||||
|
function isUsageEligible(connection) {
|
||||||
|
const isApiKey = connection.authType === "apikey" || connection.authType === "api_key";
|
||||||
|
return USAGE_SUPPORTED_PROVIDERS.includes(connection.provider) && (
|
||||||
|
connection.authType === "oauth" || (isApiKey && USAGE_APIKEY_PROVIDERS.includes(connection.provider))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProxyOptions(proxyConfig) {
|
||||||
|
return {
|
||||||
|
connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true,
|
||||||
|
connectionProxyUrl: proxyConfig.connectionProxyUrl || "",
|
||||||
|
connectionNoProxy: proxyConfig.connectionNoProxy || "",
|
||||||
|
vercelRelayUrl: proxyConfig.vercelRelayUrl || "",
|
||||||
|
strictProxy: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthenticationError(usage) {
|
||||||
|
const message = usage?.message || usage?.error || "";
|
||||||
|
return /expired|authentication|unauthorized|401|403|re-authorize/i.test(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEarliestFutureReset(currentResetAt, nextResetAt) {
|
||||||
|
const currentTime = currentResetAt ? new Date(currentResetAt).getTime() : Number.POSITIVE_INFINITY;
|
||||||
|
const nextTime = nextResetAt ? new Date(nextResetAt).getTime() : Number.POSITIVE_INFINITY;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (nextTime > now && nextTime < currentTime) return nextResetAt;
|
||||||
|
if (currentTime > now) return currentResetAt;
|
||||||
|
return nextResetAt || currentResetAt || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeProviderError(error) {
|
||||||
|
const message = String(error?.message || error || "");
|
||||||
|
if (/re-authorize|refresh credentials|no codex access token|unauthorized|authentication/i.test(message)) {
|
||||||
|
return "An administrator needs to re-authorize this provider connection.";
|
||||||
|
}
|
||||||
|
if (/temporarily unavailable|rate limit|timeout|fetch/i.test(message)) {
|
||||||
|
return "The provider quota service is temporarily unavailable.";
|
||||||
|
}
|
||||||
|
return "Quota data is temporarily unavailable for this provider.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSystemQuotaResponse(connections, results) {
|
||||||
|
const providerGroups = new Map();
|
||||||
|
|
||||||
|
for (const connection of connections) {
|
||||||
|
if (!providerGroups.has(connection.provider)) {
|
||||||
|
providerGroups.set(connection.provider, {
|
||||||
|
provider: connection.provider,
|
||||||
|
accountCount: 0,
|
||||||
|
quotaAccountCount: 0,
|
||||||
|
failedAccountCount: 0,
|
||||||
|
errorMessage: null,
|
||||||
|
quotaGroups: new Map(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
providerGroups.get(connection.provider).accountCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
const group = providerGroups.get(result.provider);
|
||||||
|
if (!group) continue;
|
||||||
|
|
||||||
|
if (result.status !== "fulfilled" || result.value.length === 0) {
|
||||||
|
group.failedAccountCount += 1;
|
||||||
|
group.errorMessage ||= result.errorMessage || "Quota data is temporarily unavailable for this provider.";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
group.quotaAccountCount += 1;
|
||||||
|
for (const quota of result.value) {
|
||||||
|
if (!quota?.name) continue;
|
||||||
|
|
||||||
|
if (!group.quotaGroups.has(quota.name)) {
|
||||||
|
group.quotaGroups.set(quota.name, {
|
||||||
|
name: quota.name,
|
||||||
|
percentageTotal: 0,
|
||||||
|
accountCount: 0,
|
||||||
|
resetAt: null,
|
||||||
|
recurring: quota.recurring !== false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotaGroup = group.quotaGroups.get(quota.name);
|
||||||
|
quotaGroup.percentageTotal += getRemainingPercentage(quota);
|
||||||
|
quotaGroup.accountCount += 1;
|
||||||
|
quotaGroup.resetAt = getEarliestFutureReset(quotaGroup.resetAt, quota.resetAt);
|
||||||
|
quotaGroup.recurring = quotaGroup.recurring && quota.recurring !== false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = Array.from(providerGroups.values())
|
||||||
|
.map((group) => ({
|
||||||
|
provider: group.provider,
|
||||||
|
accountCount: group.accountCount,
|
||||||
|
quotaAccountCount: group.quotaAccountCount,
|
||||||
|
failedAccountCount: group.failedAccountCount,
|
||||||
|
errorMessage: group.errorMessage,
|
||||||
|
quotas: Array.from(group.quotaGroups.values()).map((quota) => ({
|
||||||
|
name: quota.name,
|
||||||
|
remainingPercentage: Math.round(quota.percentageTotal / quota.accountCount),
|
||||||
|
accountCount: quota.accountCount,
|
||||||
|
resetAt: quota.resetAt,
|
||||||
|
recurring: quota.recurring,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.provider.localeCompare(b.provider));
|
||||||
|
|
||||||
|
return {
|
||||||
|
providers,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchConnectionQuota(connection) {
|
||||||
|
const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData);
|
||||||
|
const proxyOptions = getProxyOptions(proxyConfig);
|
||||||
|
let usableConnection = connection;
|
||||||
|
|
||||||
|
if (connection.authType === "oauth") {
|
||||||
|
const refreshed = await refreshAndUpdateCredentials(connection, false, proxyOptions);
|
||||||
|
usableConnection = refreshed.connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
let usage = await getUsageForProvider(usableConnection, proxyOptions);
|
||||||
|
|
||||||
|
// Match the existing per-connection quota endpoint: a provider can reject an
|
||||||
|
// otherwise unexpired OAuth access token, in which case a forced refresh and
|
||||||
|
// a single retry is required before declaring the aggregate unavailable.
|
||||||
|
if (connection.authType === "oauth" && isAuthenticationError(usage) && connection.refreshToken) {
|
||||||
|
const refreshed = await refreshAndUpdateCredentials(usableConnection, true, proxyOptions);
|
||||||
|
usableConnection = refreshed.connection;
|
||||||
|
usage = await getUsageForProvider(usableConnection, proxyOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usage?.message || usage?.error) {
|
||||||
|
throw new Error(usage.message || usage.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseQuotaData(connection.provider, usage).filter((quota) => (
|
||||||
|
Number.isFinite(getRemainingPercentage(quota))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/usage/system-quota
|
||||||
|
*
|
||||||
|
* Returns provider-level average quota availability across every eligible system
|
||||||
|
* connection. It deliberately excludes all connection, credential, and owner data.
|
||||||
|
*/
|
||||||
|
export async function GET(request) {
|
||||||
|
try {
|
||||||
|
await requireUsageDashboardUser();
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const forceRefresh = searchParams.get("refresh") === "true";
|
||||||
|
const cacheIsFresh = cachedSystemQuota && Date.now() - cachedSystemQuota.cachedAt < CACHE_TTL_MS;
|
||||||
|
|
||||||
|
if (!forceRefresh && cacheIsFresh) {
|
||||||
|
return Response.json({ ...cachedSystemQuota.data, cached: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const connections = (await getProviderConnections({})).filter(isUsageEligible);
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
connections.map(async (connection) => ({
|
||||||
|
provider: connection.provider,
|
||||||
|
quotas: await fetchConnectionQuota(connection),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizedResults = results.map((result, index) => {
|
||||||
|
if (result.status === "fulfilled") {
|
||||||
|
return {
|
||||||
|
status: result.status,
|
||||||
|
provider: result.value.provider,
|
||||||
|
value: result.value.quotas,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`[System quota] ${connections[index].provider}: ${result.reason?.message || "quota fetch failed"}`);
|
||||||
|
return {
|
||||||
|
status: result.status,
|
||||||
|
provider: connections[index].provider,
|
||||||
|
value: [],
|
||||||
|
errorMessage: sanitizeProviderError(result.reason),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = buildSystemQuotaResponse(connections, normalizedResults);
|
||||||
|
cachedSystemQuota = { data, cachedAt: Date.now() };
|
||||||
|
|
||||||
|
return Response.json({ ...data, cached: false });
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.message === "Unauthorized") {
|
||||||
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("[System quota] Failed to build aggregate quota:", error);
|
||||||
|
return Response.json({ error: "Failed to load system quota" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user