diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index 3b1e2a91da..c134b6b488 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -1076,7 +1076,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { )} - {/* Pinned Provider Quota Limits (compact, no filters) */} + {/* Pinned Provider Quota Limits */} {pinProviderQuotaToHome && ( }> ; 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]) => ( + 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 ( +
+ {connections.map(renderCard)} +
+ ); + } + // Group connections by provider, preserving the order from sortedConnections. const groups = new Map(); for (const conn of connections) { @@ -66,28 +99,7 @@ export default function QuotaCardGrid({
- {conns.map((conn) => ( - 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)}
))} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index fdc9310700..da4905ebd6 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -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(() => { + if (typeof window === "undefined") return "full"; + return localStorage.getItem(LS_LAYOUT_MODE) === "compact" ? "compact" : "full"; + }); const lastFetchTimeRef = useRef>({}); const staleProbeRef = useRef>({}); @@ -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({ - + + {layoutMode === "compact" ? "view_agenda" : "grid_view"} + + + {layoutMode === "compact" ? "Compact" : "Full"} + + + + {showFilters && ( @@ -1048,6 +1091,7 @@ export default function ProviderLimits({ onShowQuota={handleShowQuota} redeemingResetCreditId={resetCreditRedemption.redeemingResetCreditId} loadingResetCreditsId={resetCreditRedemption.loadingResetCreditsId} + compact={layoutMode === "compact"} /> diff --git a/src/app/(dashboard)/home/ProviderQuotaWidget.tsx b/src/app/(dashboard)/home/ProviderQuotaWidget.tsx index 0144ac458e..2b7227f59d 100644 --- a/src/app/(dashboard)/home/ProviderQuotaWidget.tsx +++ b/src/app/(dashboard)/home/ProviderQuotaWidget.tsx @@ -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; 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 ( +
+ {label} + + {amount} + +
+ ); + } + + return ( +
+
+ {label} + + {quota.unlimited + ? "∞" + : translateUsageOrFallback(t, "percentLeft", `${percentage}% left`, { + pct: percentage, + })} + +
+ {!quota.unlimited && } + {reset && ⏱ {reset}} +
+ ); +} + +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 ( +
+ {accountLabel &&

{accountLabel}

} + {quotas.length === 0 ? ( +

+ {cache?.message || t("noQuotaData")} +

+ ) : ( +
+ {primaryQuotas.map((quota, index) => ( +
+ +
+ ))} + {showOptional && + optionalQuotas.map((quota, index) => ( +
+ +
+ ))} +
+ )} + {optionalQuotas.length > 0 && ( + + )} +
+ ); +} + +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([]); + const [quotaData, setQuotaData] = useState({}); + const [loading, setLoading] = useState(true); + const [refreshingAll, setRefreshingAll] = useState(false); + const [updatedAt, setUpdatedAt] = useState(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([]); - const [quotaData, setQuotaData] = useState({}); - 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(); + 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>((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 ( - - {/* Header with title + Refresh All in upper right */} -
+ +
- +
-

{tr("providerQuota", "Provider Quota")}

-

- {tr("providerQuotaHomeHint", "Live status across connected accounts")} -

+

+ {tr("providerQuota", "Provider Quota")} +

+ {updatedLabel && ( +

+ {tr("updatedShort", "Updated")} {updatedLabel} +

+ )}
-
- {/* Body */} -
- {loading ? ( -
- progress_activity - {tr("loadingQuotas", "Loading...")} -
- ) : providerEntries.length === 0 ? ( -
- {tr("noProviders", "No Providers Connected")} -
- {tr( - "connectProvidersForQuota", - "Connect to providers with OAuth to track your API quota limits and usage." - )} -
-
- ) : ( -
- {providerEntries.map(([provider, conns]) => { - const firstConn = conns[0]; - const cache = quotaData[firstConn?.id]; - const hasQuota = cache?.quotas && Object.keys(cache.quotas).length > 0; - - return ( -
-
- - - {provider.charAt(0).toUpperCase() + provider.slice(1)} - - - {conns.length} - -
- - {hasQuota ? ( -
- {Object.keys(cache.quotas).length} -
- ) : ( - - )} - - {/* Future: embed small QuotaProgressBar for the primary window here */} -
- ); - })} -
- )} - -
- - {tr("viewDetails", "View details")} - - + {loading ? ( +
+ + {tr("loadingQuotas", "Loading...")}
-
+ ) : providerGroups.length === 0 ? ( +
+ {tr("noProviders", "No Providers Connected")} +
+ ) : compact ? ( + /* Compact mode: 3-column card grid, flat across all connections */ +
+ {connections.map((connection) => ( +
+
+ + + {PROVIDER_LABEL[connection.provider] || connection.provider} + +
+ +
+ ))} +
+ ) : ( +
+ {providerGroups.map(([provider, providerConnections]) => ( +
+
+ +
+

+ {PROVIDER_LABEL[provider] || provider} +

+

+ {providerConnections.length}{" "} + {providerConnections.length === 1 ? "account" : "accounts"} +

+
+
+
+ {providerConnections.map((connection) => ( + + ))} +
+
+ ))} +
+ )} ); }