feat: improve provider quota layouts

This commit is contained in:
Apostol Apostolov
2026-07-29 16:52:01 +03:00
committed by diegosouzapw
parent f11d883f22
commit 3c483dd906
4 changed files with 388 additions and 280 deletions

View File

@@ -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

View File

@@ -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>
))}

View File

@@ -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>

View File

@@ -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"> &rarr;</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>
);
}