mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 17:52:31 +03:00
feat: improve provider quota layouts
This commit is contained in:
committed by
diegosouzapw
parent
f11d883f22
commit
3c483dd906
@@ -1076,7 +1076,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pinned Provider Quota Limits (compact, no filters) */}
|
||||
{/* Pinned Provider Quota Limits */}
|
||||
{pinProviderQuotaToHome && (
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<ProviderQuotaWidget
|
||||
|
||||
@@ -23,6 +23,7 @@ interface Props {
|
||||
quotaVisibility?: Record<string, { hidden?: string[] }>;
|
||||
onHideQuota?: (provider: string, quota: any) => void;
|
||||
onShowQuota?: (provider: string, quota: any) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export default function QuotaCardGrid({
|
||||
@@ -44,9 +45,41 @@ export default function QuotaCardGrid({
|
||||
quotaVisibility,
|
||||
onHideQuota,
|
||||
onShowQuota,
|
||||
compact = false,
|
||||
}: Props) {
|
||||
if (connections.length === 0) return null;
|
||||
|
||||
const renderCard = (conn: (typeof connections)[number]) => (
|
||||
<QuotaCard
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
quota={quotaData[conn.id]}
|
||||
loading={!!loading[conn.id]}
|
||||
error={errors[conn.id] || null}
|
||||
refreshedAt={lastRefreshedAt[conn.id]}
|
||||
emailsVisible={emailsVisible}
|
||||
providerLabel={providerLabels[conn.provider] || conn.provider}
|
||||
onRefresh={() => onRefresh(conn.id, conn.provider)}
|
||||
onOpenCutoff={() => onOpenCutoff(conn)}
|
||||
onRedeemResetCredit={() => onRedeemResetCredit?.(conn.id, conn.provider)}
|
||||
onToggleActive={(nextActive) => onToggleActive(conn.id, nextActive)}
|
||||
togglingActive={togglingActiveId === conn.id}
|
||||
redeemingResetCredit={redeemingResetCreditId === conn.id}
|
||||
loadingResetCredits={loadingResetCreditsId === conn.id}
|
||||
quotaVisibility={quotaVisibility}
|
||||
onHideQuota={onHideQuota ? (q) => onHideQuota(conn.provider, q) : undefined}
|
||||
onShowQuota={onShowQuota ? (q) => onShowQuota(conn.provider, q) : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-3">
|
||||
{connections.map(renderCard)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group connections by provider, preserving the order from sortedConnections.
|
||||
const groups = new Map<string, typeof connections>();
|
||||
for (const conn of connections) {
|
||||
@@ -66,28 +99,7 @@ export default function QuotaCardGrid({
|
||||
</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-3">
|
||||
{conns.map((conn) => (
|
||||
<QuotaCard
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
quota={quotaData[conn.id]}
|
||||
loading={!!loading[conn.id]}
|
||||
error={errors[conn.id] || null}
|
||||
refreshedAt={lastRefreshedAt[conn.id]}
|
||||
emailsVisible={emailsVisible}
|
||||
providerLabel={providerLabels[conn.provider] || conn.provider}
|
||||
onRefresh={() => onRefresh(conn.id, conn.provider)}
|
||||
onOpenCutoff={() => onOpenCutoff(conn)}
|
||||
onOpenResetCredits={() => onOpenResetCredits?.(conn.id, conn.provider)}
|
||||
onToggleActive={(nextActive) => onToggleActive(conn.id, nextActive)}
|
||||
togglingActive={togglingActiveId === conn.id}
|
||||
redeemingResetCredit={redeemingResetCreditId === conn.id}
|
||||
loadingResetCredits={loadingResetCreditsId === conn.id}
|
||||
quotaVisibility={quotaVisibility}
|
||||
onHideQuota={onHideQuota ? (q) => onHideQuota(conn.provider, q) : undefined}
|
||||
onShowQuota={onShowQuota ? (q) => onShowQuota(conn.provider, q) : undefined}
|
||||
/>
|
||||
))}
|
||||
{conns.map(renderCard)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -44,6 +44,7 @@ const LS_PURCHASE_FILTER = "omniroute:limits:purchaseFilter";
|
||||
const LS_STATUS_FILTER = "omniroute:limits:statusFilter";
|
||||
const LS_ENV_FILTER = "omniroute:limits:envFilter";
|
||||
const LS_PROVIDER_FILTER = "omniroute:limits:providerFilter";
|
||||
const LS_LAYOUT_MODE = "omniroute:limits:layoutMode";
|
||||
|
||||
const MIN_FETCH_INTERVAL_MS = 30000;
|
||||
const QUOTA_BAR_GREEN_THRESHOLD = 50;
|
||||
@@ -51,6 +52,7 @@ const QUOTA_BAR_YELLOW_THRESHOLD = 20;
|
||||
|
||||
type PurchaseTypeKey = "all" | "oauth-free" | "oauth-sub" | "apikey";
|
||||
type StatusKey = "all" | "critical" | "alert" | "ok" | "empty";
|
||||
type LayoutMode = "full" | "compact";
|
||||
|
||||
const PURCHASE_TYPES: Array<{ key: PurchaseTypeKey; labelKey: string; fallback: string }> = [
|
||||
{ key: "all", labelKey: "purchaseAll", fallback: "All" },
|
||||
@@ -231,6 +233,10 @@ export default function ProviderLimits({
|
||||
if (typeof window === "undefined") return "all";
|
||||
return localStorage.getItem(LS_PROVIDER_FILTER) || "all";
|
||||
});
|
||||
const [layoutMode, setLayoutMode] = useState<LayoutMode>(() => {
|
||||
if (typeof window === "undefined") return "full";
|
||||
return localStorage.getItem(LS_LAYOUT_MODE) === "compact" ? "compact" : "full";
|
||||
});
|
||||
|
||||
const lastFetchTimeRef = useRef<Record<string, number>>({});
|
||||
const staleProbeRef = useRef<Record<string, number>>({});
|
||||
@@ -746,6 +752,18 @@ export default function ProviderLimits({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleLayoutMode = useCallback(() => {
|
||||
setLayoutMode((current) => {
|
||||
const next = current === "full" ? "compact" : "full";
|
||||
try {
|
||||
localStorage.setItem(LS_LAYOUT_MODE, next);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renderInlineQuotaSummary = (quotas: any[]) => {
|
||||
if (!quotas || quotas.length === 0) return null;
|
||||
return (
|
||||
@@ -816,30 +834,55 @@ export default function ProviderLimits({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={refreshAll}
|
||||
disabled={refreshingAll}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-bg-subtle border border-border text-text-main text-[13px] disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
title={
|
||||
autoRefreshIntervalMs > 0 ? tr("autoRefreshing", "Auto-refreshing") : t("refreshAll")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLayoutMode}
|
||||
aria-pressed={layoutMode === "compact"}
|
||||
aria-label={
|
||||
layoutMode === "compact"
|
||||
? "Switch to full quota layout"
|
||||
: "Switch to compact quota layout"
|
||||
}
|
||||
title={
|
||||
layoutMode === "compact"
|
||||
? "Switch to full quota layout"
|
||||
: "Switch to compact quota layout"
|
||||
}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-bg-subtle border border-border text-text-main text-[13px] cursor-pointer"
|
||||
>
|
||||
{autoRefreshIntervalMs > 0 ? "schedule" : "refresh"}
|
||||
</span>
|
||||
{refreshingAll
|
||||
? tr("refreshing", "Refreshing")
|
||||
: autoRefreshIntervalMs > 0
|
||||
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
|
||||
Math.max(
|
||||
0,
|
||||
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
|
||||
)
|
||||
)}`
|
||||
: t("refreshAll")}
|
||||
</button>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden>
|
||||
{layoutMode === "compact" ? "view_agenda" : "grid_view"}
|
||||
</span>
|
||||
<span className="hidden sm:inline">
|
||||
{layoutMode === "compact" ? "Compact" : "Full"}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={refreshAll}
|
||||
disabled={refreshingAll}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-bg-subtle border border-border text-text-main text-[13px] disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
title={
|
||||
autoRefreshIntervalMs > 0 ? tr("autoRefreshing", "Auto-refreshing") : t("refreshAll")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
|
||||
>
|
||||
{autoRefreshIntervalMs > 0 ? "schedule" : "refresh"}
|
||||
</span>
|
||||
{refreshingAll
|
||||
? tr("refreshing", "Refreshing")
|
||||
: autoRefreshIntervalMs > 0
|
||||
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
|
||||
Math.max(
|
||||
0,
|
||||
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
|
||||
)
|
||||
)}`
|
||||
: t("refreshAll")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
@@ -1048,6 +1091,7 @@ export default function ProviderLimits({
|
||||
onShowQuota={handleShowQuota}
|
||||
redeemingResetCreditId={resetCreditRedemption.redeemingResetCreditId}
|
||||
loadingResetCreditsId={resetCreditRedemption.loadingResetCreditsId}
|
||||
compact={layoutMode === "compact"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,26 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Card from "@/shared/components/Card";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
||||
import QuotaMiniBar from "../dashboard/usage/components/ProviderLimits/QuotaMiniBar";
|
||||
import { PROVIDER_LABEL } from "../dashboard/usage/components/ProviderLimits/constants";
|
||||
import { translateUsageOrFallback } from "../dashboard/usage/components/ProviderLimits/i18nFallback";
|
||||
import { isProviderQuotaVisible } from "@/shared/utils/providerQuotaVisibility";
|
||||
import { parseQuotaData } from "../dashboard/usage/components/ProviderLimits/quotaParsing";
|
||||
import {
|
||||
formatCountdown,
|
||||
formatQuotaLabel,
|
||||
getBarColor,
|
||||
getQuotaRemainingPercentage,
|
||||
} from "../dashboard/usage/components/ProviderLimits/utils";
|
||||
|
||||
const PRIMARY_QUOTA_COUNT = 3;
|
||||
|
||||
type Connection = {
|
||||
id: string;
|
||||
provider: string;
|
||||
authType?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
quotaVisible?: boolean;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
type QuotaData = Record<string, any>;
|
||||
|
||||
interface ProviderQuotaWidgetProps {
|
||||
autoRefreshInterval?: number;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function formatUpdatedAt(updatedAt: number | null): string | null {
|
||||
if (!updatedAt) return null;
|
||||
return new Date(updatedAt).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function formatAutoRefreshCountdown(ms: number): string {
|
||||
@@ -30,23 +50,172 @@ function formatAutoRefreshCountdown(ms: number): string {
|
||||
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function AutoRefreshButtonLabel({
|
||||
autoRefreshIntervalMs,
|
||||
lastRefreshAllAt,
|
||||
refreshingAll,
|
||||
tr,
|
||||
}: {
|
||||
autoRefreshIntervalMs: number;
|
||||
lastRefreshAllAt: number;
|
||||
refreshingAll: boolean;
|
||||
tr: (key: string, fallback: string) => string;
|
||||
}) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
function QuotaRow({ quota }: { quota: any }) {
|
||||
const t = useTranslations("usage");
|
||||
const percentage = Math.round(getQuotaRemainingPercentage(quota));
|
||||
const colors = getBarColor(percentage);
|
||||
const label = quota.displayName || formatQuotaLabel(quota.name) || quota.name;
|
||||
const reset = formatCountdown(quota.resetAt);
|
||||
|
||||
if (quota.isCredits || quota.isResetCredits) {
|
||||
const amount = Number(quota.creditCount ?? quota.remaining ?? 0).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-between gap-3 py-1.5">
|
||||
<span className="min-w-0 truncate text-xs font-medium text-text-main">{label}</span>
|
||||
<span className="shrink-0 text-xs font-bold tabular-nums" style={{ color: colors.text }}>
|
||||
{amount}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1 py-1.5" title={quota.modelKey || quota.name}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="min-w-0 truncate text-xs font-medium text-text-main">{label}</span>
|
||||
<span className="shrink-0 text-xs font-bold tabular-nums" style={{ color: colors.text }}>
|
||||
{quota.unlimited
|
||||
? "∞"
|
||||
: translateUsageOrFallback(t, "percentLeft", `${percentage}% left`, {
|
||||
pct: percentage,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{!quota.unlimited && <QuotaMiniBar percent={percentage} />}
|
||||
{reset && <span className="text-[10px] text-text-muted">⏱ {reset}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionQuotas({ connection, cache }: { connection: Connection; cache: any }) {
|
||||
const t = useTranslations("usage");
|
||||
const [showOptional, setShowOptional] = useState(false);
|
||||
const quotas = useMemo(
|
||||
() => parseQuotaData(connection.provider, cache),
|
||||
[cache, connection.provider]
|
||||
);
|
||||
const primaryQuotas = quotas.slice(0, PRIMARY_QUOTA_COUNT);
|
||||
const optionalQuotas = quotas.slice(PRIMARY_QUOTA_COUNT);
|
||||
const accountLabel = connection.name || connection.displayName || connection.email;
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
{accountLabel && <p className="mb-1 text-[11px] text-text-muted truncate">{accountLabel}</p>}
|
||||
{quotas.length === 0 ? (
|
||||
<p className="py-1.5 text-xs italic text-text-muted">
|
||||
{cache?.message || t("noQuotaData")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-x-6 sm:grid-cols-2">
|
||||
{primaryQuotas.map((quota, index) => (
|
||||
<div
|
||||
key={`${quota.name}-${quota.modelKey || ""}-${index}`}
|
||||
className="border-b border-border/40"
|
||||
>
|
||||
<QuotaRow quota={quota} />
|
||||
</div>
|
||||
))}
|
||||
{showOptional &&
|
||||
optionalQuotas.map((quota, index) => (
|
||||
<div
|
||||
key={`${quota.name}-${quota.modelKey || ""}-${index}`}
|
||||
className="border-b border-border/40"
|
||||
>
|
||||
<QuotaRow quota={quota} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{optionalQuotas.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOptional((current) => !current)}
|
||||
className="mt-2 inline-flex items-center gap-1 rounded-md border border-border bg-bg-subtle px-2 py-1 text-[11px] font-medium text-text-main hover:bg-surface transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]" aria-hidden="true">
|
||||
{showOptional ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
{showOptional
|
||||
? t("showLessQuotas")
|
||||
: t("showMoreQuotas", { count: optionalQuotas.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProviderQuotaWidget({
|
||||
autoRefreshInterval = 0,
|
||||
compact = false,
|
||||
}: ProviderQuotaWidgetProps) {
|
||||
const t = useTranslations("usage");
|
||||
const tr = useCallback(
|
||||
(key: string, fallback: string) => translateUsageOrFallback(t, key, fallback),
|
||||
[t]
|
||||
);
|
||||
const [connections, setConnections] = useState<Connection[]>([]);
|
||||
const [quotaData, setQuotaData] = useState<QuotaData>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
|
||||
const refreshingAllRef = useRef(false);
|
||||
const lastRefreshAllAtRef = useRef(Date.now());
|
||||
const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0;
|
||||
const [autoRefreshClock, setAutoRefreshClock] = useState(() => Date.now());
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [connectionsResponse, quotasResponse] = await Promise.all([
|
||||
fetch("/api/providers/client"),
|
||||
fetch("/api/usage/provider-limits"),
|
||||
]);
|
||||
const connectionData = connectionsResponse.ok ? await connectionsResponse.json() : {};
|
||||
const quotaResponseData = quotasResponse.ok ? await quotasResponse.json() : {};
|
||||
const relevant = ((connectionData.connections || []) as Connection[]).filter(
|
||||
(connection) =>
|
||||
USAGE_SUPPORTED_PROVIDERS.includes(connection.provider) &&
|
||||
(connection.authType === "oauth" || connection.authType === "apikey")
|
||||
);
|
||||
setConnections(relevant);
|
||||
setQuotaData(quotaResponseData.caches || {});
|
||||
setUpdatedAt(Date.now());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRefreshIntervalMs <= 0 || refreshingAll) return;
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const tick = () => setNow(Date.now());
|
||||
const refreshAll = useCallback(async () => {
|
||||
if (refreshingAllRef.current) return;
|
||||
refreshingAllRef.current = true;
|
||||
const now = Date.now();
|
||||
lastRefreshAllAtRef.current = now;
|
||||
setAutoRefreshClock(now);
|
||||
setRefreshingAll(true);
|
||||
try {
|
||||
const response = await fetch("/api/usage/provider-limits", { method: "POST" });
|
||||
if (!response.ok) throw new Error("Failed to refresh provider quotas");
|
||||
const data = await response.json();
|
||||
setQuotaData(data.caches || {});
|
||||
setUpdatedAt(Date.now());
|
||||
} catch (error) {
|
||||
console.error("ProviderQuotaWidget refreshAll error:", error);
|
||||
} finally {
|
||||
refreshingAllRef.current = false;
|
||||
setRefreshingAll(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRefreshIntervalMs <= 0) return;
|
||||
|
||||
const tick = () => setAutoRefreshClock(Date.now());
|
||||
tick();
|
||||
|
||||
const timer = window.setInterval(tick, 1000);
|
||||
@@ -59,248 +228,131 @@ export function AutoRefreshButtonLabel({
|
||||
window.clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [autoRefreshIntervalMs, refreshingAll, lastRefreshAllAt]);
|
||||
|
||||
if (refreshingAll) {
|
||||
return <>{tr("refreshing", "Refreshing")}</>;
|
||||
}
|
||||
|
||||
if (autoRefreshIntervalMs <= 0) {
|
||||
return <>{tr("refreshAll", "Refresh All")}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{tr("autoRefreshing", "Auto-refreshing")}{" "}
|
||||
{formatAutoRefreshCountdown(Math.max(0, autoRefreshIntervalMs - (now - lastRefreshAllAt)))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: ProviderQuotaWidgetProps) {
|
||||
const t = useTranslations("usage");
|
||||
const tr = useCallback(
|
||||
(key: string, fallback: string) => translateUsageOrFallback(t, key, fallback),
|
||||
[t]
|
||||
);
|
||||
|
||||
const [connections, setConnections] = useState<Connection[]>([]);
|
||||
const [quotaData, setQuotaData] = useState<QuotaData>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||
|
||||
const refreshingAllRef = useRef(false);
|
||||
const lastRefreshAllAtRef = useRef(Date.now());
|
||||
const [lastRefreshAllAt, setLastRefreshAllAt] = useState(() => lastRefreshAllAtRef.current);
|
||||
const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0;
|
||||
|
||||
const fetchConnections = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/providers/client");
|
||||
if (!res.ok) throw new Error("Failed to load connections");
|
||||
const data = await res.json();
|
||||
return (data.connections || []) as Connection[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchCached = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/usage/provider-limits");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const data = await res.json();
|
||||
return data.caches || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const [conns, caches] = await Promise.all([fetchConnections(), fetchCached()]);
|
||||
|
||||
// Only keep connections that are usage/quota supported
|
||||
const relevant = conns.filter(
|
||||
(c) =>
|
||||
isProviderQuotaVisible(c) &&
|
||||
USAGE_SUPPORTED_PROVIDERS.includes(c.provider) &&
|
||||
(c.authType === "oauth" || c.authType === "apikey")
|
||||
);
|
||||
|
||||
setConnections(relevant);
|
||||
setQuotaData(caches);
|
||||
setLoading(false);
|
||||
}, [fetchConnections, fetchCached]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
if (refreshingAllRef.current) return;
|
||||
refreshingAllRef.current = true;
|
||||
const now = Date.now();
|
||||
lastRefreshAllAtRef.current = now;
|
||||
setLastRefreshAllAt(now);
|
||||
setRefreshingAll(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/usage/provider-limits", { method: "POST" });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || "Refresh failed");
|
||||
}
|
||||
const data = await res.json();
|
||||
setQuotaData(data.caches || {});
|
||||
} catch (e) {
|
||||
console.error("ProviderQuotaWidget refreshAll error:", e);
|
||||
} finally {
|
||||
refreshingAllRef.current = false;
|
||||
setRefreshingAll(false);
|
||||
}
|
||||
}, []);
|
||||
}, [autoRefreshIntervalMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRefreshIntervalMs <= 0) return;
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (refreshingAllRef.current) return;
|
||||
|
||||
const maybeRefresh = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (refreshingAllRef.current) return;
|
||||
if (Date.now() - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) {
|
||||
void refreshAll();
|
||||
}
|
||||
};
|
||||
if (autoRefreshClock - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) {
|
||||
void refreshAll();
|
||||
}
|
||||
}, [autoRefreshClock, autoRefreshIntervalMs, refreshAll]);
|
||||
|
||||
maybeRefresh();
|
||||
const timer = window.setInterval(maybeRefresh, 1000);
|
||||
const handleVisibilityChange = () => maybeRefresh();
|
||||
const providerGroups = useMemo(() => {
|
||||
const groups = new Map<string, Connection[]>();
|
||||
for (const connection of connections) {
|
||||
const group = groups.get(connection.provider) || [];
|
||||
group.push(connection);
|
||||
groups.set(connection.provider, group);
|
||||
}
|
||||
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [connections]);
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [autoRefreshIntervalMs, refreshAll]);
|
||||
|
||||
// Simple summary: group by provider for display
|
||||
const providerGroups = connections.reduce<Record<string, Connection[]>>((acc, conn) => {
|
||||
if (!acc[conn.provider]) acc[conn.provider] = [];
|
||||
acc[conn.provider].push(conn);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const providerEntries = Object.entries(providerGroups).sort(([a], [b]) => a.localeCompare(b));
|
||||
const updatedLabel = formatUpdatedAt(updatedAt);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
{/* Header with title + Refresh All in upper right */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3 bg-surface/60">
|
||||
<Card className="w-full overflow-hidden">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border bg-surface/60 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary text-[20px]">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary" aria-hidden="true">
|
||||
account_balance
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-base">{tr("providerQuota", "Provider Quota")}</h3>
|
||||
<p className="text-[11px] text-text-muted -mt-0.5">
|
||||
{tr("providerQuotaHomeHint", "Live status across connected accounts")}
|
||||
</p>
|
||||
<h2 className="text-base font-semibold text-text-main">
|
||||
{tr("providerQuota", "Provider Quota")}
|
||||
</h2>
|
||||
{updatedLabel && (
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{tr("updatedShort", "Updated")} {updatedLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={refreshAll}
|
||||
disabled={refreshingAll || loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-bg-subtle text-xs font-medium text-text-main disabled:opacity-50 disabled:cursor-not-allowed hover:bg-surface transition-colors"
|
||||
title={
|
||||
autoRefreshIntervalMs > 0
|
||||
? tr("autoRefreshing", "Auto-refreshing")
|
||||
: tr("refreshAll", "Refresh All")
|
||||
}
|
||||
disabled={loading || refreshingAll}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-bg-subtle px-3 py-1.5 text-xs font-medium text-text-main transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{autoRefreshIntervalMs > 0 ? "schedule" : "refresh"}
|
||||
</span>
|
||||
<span>
|
||||
<AutoRefreshButtonLabel
|
||||
autoRefreshIntervalMs={autoRefreshIntervalMs}
|
||||
lastRefreshAllAt={lastRefreshAllAt}
|
||||
refreshingAll={refreshingAll}
|
||||
tr={tr}
|
||||
/>
|
||||
</span>
|
||||
{refreshingAll
|
||||
? tr("refreshing", "Refreshing")
|
||||
: autoRefreshIntervalMs > 0
|
||||
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
|
||||
Math.max(
|
||||
0,
|
||||
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
|
||||
)
|
||||
)}`
|
||||
: tr("forceRefresh", "Refresh now")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8 text-text-muted text-sm">
|
||||
<span className="material-symbols-outlined animate-spin mr-2">progress_activity</span>
|
||||
{tr("loadingQuotas", "Loading...")}
|
||||
</div>
|
||||
) : providerEntries.length === 0 ? (
|
||||
<div className="text-center py-6 text-sm text-text-muted">
|
||||
{tr("noProviders", "No Providers Connected")}
|
||||
<div className="mt-1 text-xs">
|
||||
{tr(
|
||||
"connectProvidersForQuota",
|
||||
"Connect to providers with OAuth to track your API quota limits and usage."
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{providerEntries.map(([provider, conns]) => {
|
||||
const firstConn = conns[0];
|
||||
const cache = quotaData[firstConn?.id];
|
||||
const hasQuota = cache?.quotas && Object.keys(cache.quotas).length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className="rounded-lg border border-border bg-surface/40 p-3 flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon providerId={provider} size={18} />
|
||||
<span className="font-medium text-sm truncate">
|
||||
{provider.charAt(0).toUpperCase() + provider.slice(1)}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted ml-auto tabular-nums">
|
||||
{conns.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasQuota ? (
|
||||
<div className="text-xs text-text-muted" title={tr("details", "Details")}>
|
||||
{Object.keys(cache.quotas).length}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={refreshAll}
|
||||
className="text-left text-xs text-amber-600 dark:text-amber-500 hover:underline"
|
||||
>
|
||||
{tr("refreshAll", "Refresh All")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Future: embed small QuotaProgressBar for the primary window here */}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 text-[11px] text-right text-text-muted">
|
||||
<a href="/dashboard/usage?tab=limits" className="hover:text-primary hover:underline">
|
||||
{tr("viewDetails", "View details")}
|
||||
<span aria-hidden="true"> →</span>
|
||||
</a>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-4 py-8 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin text-[16px]" aria-hidden="true">
|
||||
progress_activity
|
||||
</span>
|
||||
{tr("loadingQuotas", "Loading...")}
|
||||
</div>
|
||||
</div>
|
||||
) : providerGroups.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-text-muted">
|
||||
{tr("noProviders", "No Providers Connected")}
|
||||
</div>
|
||||
) : compact ? (
|
||||
/* Compact mode: 3-column card grid, flat across all connections */
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3 p-4">
|
||||
{connections.map((connection) => (
|
||||
<div key={connection.id} className="border border-border rounded-lg p-3 bg-bg-subtle">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ProviderIcon providerId={connection.provider} size={16} />
|
||||
<span className="text-xs font-semibold text-text-main truncate">
|
||||
{PROVIDER_LABEL[connection.provider] || connection.provider}
|
||||
</span>
|
||||
</div>
|
||||
<ConnectionQuotas connection={connection} cache={quotaData[connection.id]} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{providerGroups.map(([provider, providerConnections]) => (
|
||||
<section
|
||||
key={provider}
|
||||
className="grid grid-cols-1 gap-4 px-4 py-4 lg:grid-cols-[12rem_minmax(0,1fr)]"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 lg:items-start">
|
||||
<ProviderIcon providerId={provider} size={20} />
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-sm font-semibold text-text-main">
|
||||
{PROVIDER_LABEL[provider] || provider}
|
||||
</h3>
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{providerConnections.length}{" "}
|
||||
{providerConnections.length === 1 ? "account" : "accounts"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{providerConnections.map((connection) => (
|
||||
<ConnectionQuotas
|
||||
key={connection.id}
|
||||
connection={connection}
|
||||
cache={quotaData[connection.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user