| "use client"; |
|
|
| import { useState, useEffect, useCallback, useRef, useMemo } from "react"; |
| import ProviderIcon from "@/shared/components/ProviderIcon"; |
| import QuotaTable from "./QuotaTable"; |
| import Toggle from "@/shared/components/Toggle"; |
| import { parseQuotaData, calculatePercentage } from "./utils"; |
| import Card from "@/shared/components/Card"; |
| import { EditConnectionModal } from "@/shared/components"; |
| import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; |
|
|
| function getConnectionLabel(connection) { |
| const isEmail = (value) => |
| typeof value === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); |
| if (isEmail(connection.email)) return connection.email; |
| if (isEmail(connection.name)) return connection.name; |
| return connection.name; |
| } |
|
|
| function getConnectionQuotaRemaining(connection, quotaData) { |
| const quota = quotaData[connection.id]?.quotas?.[0]; |
| if (!quota) return Number.POSITIVE_INFINITY; |
| if (typeof quota.remaining === "number") return quota.remaining; |
| return Number.POSITIVE_INFINITY; |
| } |
|
|
| function sortVisibleConnections( |
| connections, |
| quotaData, |
| expiringFirst, |
| providerFilter, |
| quotaSortMode, |
| ) { |
| if (providerFilter === "codex" && quotaSortMode !== "default") { |
| return [...connections].sort((a, b) => { |
| const remainingA = getConnectionQuotaRemaining(a, quotaData); |
| const remainingB = getConnectionQuotaRemaining(b, quotaData); |
| const remainingDiff = |
| quotaSortMode === "remaining-asc" |
| ? remainingA - remainingB |
| : remainingB - remainingA; |
|
|
| if (remainingDiff !== 0) return remainingDiff; |
| return (getConnectionLabel(a) || "").localeCompare( |
| getConnectionLabel(b) || "", |
| ); |
| }); |
| } |
|
|
| if (!expiringFirst) return connections; |
|
|
| const getEarliestResetTime = (connection) => { |
| const resetTimes = (quotaData[connection.id]?.quotas || []) |
| .map((quota) => |
| quota.resetAt |
| ? new Date(quota.resetAt).getTime() |
| : Number.POSITIVE_INFINITY, |
| ) |
| .filter((time) => Number.isFinite(time)); |
| return resetTimes.length > 0 |
| ? Math.min(...resetTimes) |
| : Number.POSITIVE_INFINITY; |
| }; |
|
|
| return [...connections].sort((a, b) => { |
| const expiryDiff = getEarliestResetTime(a) - getEarliestResetTime(b); |
| if (expiryDiff !== 0) return expiryDiff; |
| return ( |
| (a.provider || "").localeCompare(b.provider || "") || |
| (getConnectionLabel(a) || "").localeCompare(getConnectionLabel(b) || "") |
| ); |
| }); |
| } |
|
|
| function buildLoadingState(connections) { |
| const nextLoadingState = {}; |
| connections.forEach((connection) => { |
| nextLoadingState[connection.id] = true; |
| }); |
| return nextLoadingState; |
| } |
|
|
| function filterQuotaStateByConnections(state, connections) { |
| const visibleIds = new Set(connections.map((connection) => connection.id)); |
| return Object.fromEntries( |
| Object.entries(state).filter(([id]) => visibleIds.has(id)), |
| ); |
| } |
|
|
| function getConnectionsPageRange(pagination) { |
| if (!pagination.total) { |
| return { start: 0, end: 0 }; |
| } |
|
|
| const start = (pagination.page - 1) * pagination.pageSize + 1; |
| const end = Math.min(pagination.page * pagination.pageSize, pagination.total); |
| return { start, end }; |
| } |
|
|
| function getConnectionsEmptyMessage(totals, providerFilter, accountFilter) { |
| if (!totals.eligibleConnections) { |
| return { |
| icon: "cloud_off", |
| title: "No Providers Connected", |
| description: |
| "Connect to providers with OAuth to track your API quota limits and usage.", |
| }; |
| } |
|
|
| if (!totals.providerFilteredConnections) { |
| return { |
| icon: "filter_alt_off", |
| title: "No Accounts Match Current Filters", |
| description: |
| providerFilter === "all" |
| ? "Try changing the account status filter to see more quota trackers." |
| : `No ${accountFilter === "inactive" ? "turned off" : accountFilter === "active" ? "active" : "matching"} accounts found for ${providerFilter}.`, |
| }; |
| } |
|
|
| return { |
| icon: "filter_alt_off", |
| title: "No Accounts On This Page", |
| description: |
| "Try moving to another page or refreshing the current filters.", |
| }; |
| } |
|
|
| function sortRequestFromExpiringFirst(expiringFirst) { |
| return expiringFirst ? "expiring" : "priority"; |
| } |
|
|
| function getPageSizeLabel(pageSize, isCustomPageSize) { |
| return isCustomPageSize ? `Custom: ${pageSize} / page` : `${pageSize} / page`; |
| } |
|
|
| function getConnectionsPaginationSummary(pagination) { |
| const { start, end } = getConnectionsPageRange(pagination); |
| return `Showing ${start}-${end} of ${pagination.total}`; |
| } |
|
|
| function getSafePagination(pagination, fallbackPageSize) { |
| return ( |
| pagination || { |
| page: 1, |
| pageSize: fallbackPageSize, |
| total: 0, |
| totalPages: 1, |
| } |
| ); |
| } |
|
|
| function getSafeTotals(totals, fallbackTotal = 0) { |
| return ( |
| totals || { |
| eligibleConnections: fallbackTotal, |
| providerFilteredConnections: fallbackTotal, |
| } |
| ); |
| } |
|
|
| function shouldResetPage(previousValue, nextValue) { |
| return previousValue !== nextValue; |
| } |
|
|
| function getPaginationPageValue(dataPagination, fallbackPage) { |
| return dataPagination?.page || fallbackPage; |
| } |
|
|
| function getProviderOptions(dataProviderOptions) { |
| return dataProviderOptions || []; |
| } |
|
|
| async function reconcileConnectionsPage(fetchConnections, targetPage) { |
| const nextConnections = await fetchConnections(targetPage); |
| return nextConnections; |
| } |
|
|
| const QUOTA_CACHE_KEY = "quotaCacheData"; |
|
|
| function getQuotaCache() { |
| if (typeof window === "undefined") return {}; |
| try { |
| const cached = window.localStorage.getItem(QUOTA_CACHE_KEY); |
| return cached ? JSON.parse(cached) : {}; |
| } catch (error) { |
| console.error("Error reading quota cache:", error); |
| return {}; |
| } |
| } |
|
|
| function setQuotaCache(connectionId, quotaEntry) { |
| if (typeof window === "undefined") return; |
| try { |
| const cache = getQuotaCache(); |
| cache[connectionId] = { |
| ...quotaEntry, |
| cachedAt: new Date().toISOString(), |
| }; |
| window.localStorage.setItem(QUOTA_CACHE_KEY, JSON.stringify(cache)); |
| } catch (error) { |
| console.error("Error writing quota cache:", error); |
| } |
| } |
|
|
| const REFRESH_INTERVAL_MS = 60000; |
| const DEPLETED_QUOTA_THRESHOLD = 5; |
| const AUTO_REFRESH_STORAGE_KEY = "quotaAutoRefresh"; |
| const ACCOUNT_FILTER_OPTIONS = [ |
| { value: "all", label: "All accounts" }, |
| { value: "active", label: "Active" }, |
| { value: "inactive", label: "Turned off" }, |
| ]; |
| const QUOTA_SORT_OPTIONS = [ |
| { value: "default", label: "Default quota order" }, |
| { value: "remaining-asc", label: "% quota: low to high" }, |
| { value: "remaining-desc", label: "% quota: high to low" }, |
| ]; |
| const CONNECTIONS_PAGE_SIZE = 20; |
| const ACCOUNT_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; |
| const ACCOUNT_PAGE_SIZE_MAX = 500; |
|
|
| export default function ProviderLimits() { |
| const [connections, setConnections] = useState([]); |
| const [quotaData, setQuotaData] = useState({}); |
| const [loading, setLoading] = useState({}); |
| const [errors, setErrors] = useState({}); |
| const [autoRefresh, setAutoRefresh] = useState(true); |
| const [lastUpdated, setLastUpdated] = useState(null); |
| const [hasHydratedAutoRefresh, setHasHydratedAutoRefresh] = useState(false); |
| const [refreshingAll, setRefreshingAll] = useState(false); |
| const [countdown, setCountdown] = useState(60); |
| const [connectionsLoading, setConnectionsLoading] = useState(true); |
| const [deletingId, setDeletingId] = useState(null); |
| const [togglingId, setTogglingId] = useState(null); |
| const [showEditModal, setShowEditModal] = useState(false); |
| const [selectedConnection, setSelectedConnection] = useState(null); |
| const [proxyPools, setProxyPools] = useState([]); |
| const [providerFilter, setProviderFilter] = useState("all"); |
| const [providerOptions, setProviderOptions] = useState([]); |
| const [accountFilter, setAccountFilter] = useState("all"); |
| const [quotaSortMode, setQuotaSortMode] = useState("default"); |
| const [expiringFirst, setExpiringFirst] = useState(false); |
| const [providerMenuOpen, setProviderMenuOpen] = useState(false); |
| const [bulkToggling, setBulkToggling] = useState(false); |
| const [page, setPage] = useState(1); |
| const [pageSize, setPageSize] = useState(CONNECTIONS_PAGE_SIZE); |
| const [customPageSizeInput, setCustomPageSizeInput] = useState( |
| String(CONNECTIONS_PAGE_SIZE), |
| ); |
| const [pagination, setPagination] = useState({ |
| page: 1, |
| pageSize: CONNECTIONS_PAGE_SIZE, |
| total: 0, |
| totalPages: 1, |
| }); |
| const [totals, setTotals] = useState({ |
| eligibleConnections: 0, |
| providerFilteredConnections: 0, |
| }); |
|
|
| const intervalRef = useRef(null); |
| const countdownRef = useRef(null); |
|
|
| const fetchConnections = useCallback( |
| async (targetPage = page) => { |
| try { |
| const params = new URLSearchParams({ |
| page: String(targetPage), |
| pageSize: String(pageSize), |
| accountStatus: accountFilter, |
| sort: "priority", |
| }); |
|
|
| if (providerFilter !== "all") { |
| params.set("provider", providerFilter); |
| } |
|
|
| const response = await fetch( |
| `/api/providers/client?${params.toString()}`, |
| ); |
| if (!response.ok) throw new Error("Failed to fetch connections"); |
|
|
| const data = await response.json(); |
| const connectionList = data.connections || []; |
| const nextPagination = getSafePagination(data.pagination, pageSize); |
| const nextTotals = getSafeTotals(data.totals, connectionList.length); |
|
|
| setConnections(connectionList); |
| setProviderOptions(getProviderOptions(data.providerOptions)); |
| setPagination(nextPagination); |
| setTotals(nextTotals); |
| setPage(getPaginationPageValue(data.pagination, targetPage)); |
| return connectionList; |
| } catch (error) { |
| console.error("Error fetching connections:", error); |
| setConnections([]); |
| setProviderOptions([]); |
| setPagination({ page: 1, pageSize, total: 0, totalPages: 1 }); |
| setTotals({ eligibleConnections: 0, providerFilteredConnections: 0 }); |
| return []; |
| } |
| }, |
| [accountFilter, expiringFirst, page, pageSize, providerFilter], |
| ); |
|
|
| |
| const fetchQuota = useCallback(async (connectionId, provider) => { |
| setLoading((prev) => ({ ...prev, [connectionId]: true })); |
| setErrors((prev) => ({ ...prev, [connectionId]: null })); |
|
|
| try { |
| console.log( |
| `[ProviderLimits] Fetching quota for ${provider} (${connectionId})`, |
| ); |
| const response = await fetch(`/api/usage/${connectionId}`); |
|
|
| if (!response.ok) { |
| const errorData = await response.json().catch(() => ({})); |
| const errorMsg = errorData.error || response.statusText; |
|
|
| |
| if (response.status === 404) { |
| |
| console.warn( |
| `[ProviderLimits] Connection not found for ${provider}, skipping`, |
| ); |
| return; |
| } |
|
|
| if (response.status === 401) { |
| |
| console.warn( |
| `[ProviderLimits] Auth error for ${provider}:`, |
| errorMsg, |
| ); |
| const quotaEntry = { |
| quotas: [], |
| message: errorMsg, |
| }; |
| setQuotaData((prev) => ({ |
| ...prev, |
| [connectionId]: quotaEntry, |
| })); |
| setQuotaCache(connectionId, quotaEntry); |
| return; |
| } |
|
|
| throw new Error(`HTTP ${response.status}: ${errorMsg}`); |
| } |
|
|
| const data = await response.json(); |
| console.log(`[ProviderLimits] Got quota for ${provider}:`, data); |
|
|
| |
| const parsedQuotas = parseQuotaData(provider, data); |
|
|
| const quotaEntry = { |
| quotas: parsedQuotas, |
| plan: data.plan || null, |
| message: data.message || null, |
| raw: data, |
| }; |
|
|
| setQuotaData((prev) => ({ |
| ...prev, |
| [connectionId]: quotaEntry, |
| })); |
| setQuotaCache(connectionId, quotaEntry); |
| } catch (error) { |
| console.error( |
| `[ProviderLimits] Error fetching quota for ${provider} (${connectionId}):`, |
| error, |
| ); |
| setErrors((prev) => ({ |
| ...prev, |
| [connectionId]: error.message || "Failed to fetch quota", |
| })); |
| } finally { |
| setLoading((prev) => ({ ...prev, [connectionId]: false })); |
| } |
| }, []); |
|
|
| |
| const refreshProvider = useCallback( |
| async (connectionId, provider) => { |
| await fetchQuota(connectionId, provider); |
| setLastUpdated(new Date()); |
| }, |
| [fetchQuota], |
| ); |
|
|
| const handleDeleteConnection = useCallback( |
| async (id) => { |
| if (!confirm("Delete this connection?")) return; |
| setDeletingId(id); |
| try { |
| const res = await fetch(`/api/providers/${id}`, { method: "DELETE" }); |
| if (res.ok) { |
| setQuotaData((prev) => { |
| const next = { ...prev }; |
| delete next[id]; |
| return next; |
| }); |
| setLoading((prev) => { |
| const next = { ...prev }; |
| delete next[id]; |
| return next; |
| }); |
| setErrors((prev) => { |
| const next = { ...prev }; |
| delete next[id]; |
| return next; |
| }); |
|
|
| if (typeof window !== "undefined") { |
| try { |
| const cache = getQuotaCache(); |
| if (cache[id]) { |
| delete cache[id]; |
| window.localStorage.setItem( |
| QUOTA_CACHE_KEY, |
| JSON.stringify(cache), |
| ); |
| } |
| } catch (e) { |
| console.error("Error deleting cache entry:", e); |
| } |
| } |
|
|
| await reconcileConnectionsPage(fetchConnections, page); |
| } |
| } catch (error) { |
| console.error("Error deleting connection:", error); |
| } finally { |
| setDeletingId(null); |
| } |
| }, |
| [fetchConnections, page], |
| ); |
|
|
| const handleToggleConnectionActive = useCallback( |
| async (id, isActive) => { |
| setTogglingId(id); |
| try { |
| const res = await fetch(`/api/providers/${id}`, { |
| method: "PUT", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ isActive }), |
| }); |
| if (res.ok) { |
| setQuotaData((prev) => { |
| const next = { ...prev }; |
| return next; |
| }); |
| await reconcileConnectionsPage(fetchConnections, page); |
| } |
| } catch (error) { |
| console.error("Error updating connection status:", error); |
| } finally { |
| setTogglingId(null); |
| } |
| }, |
| [fetchConnections, page], |
| ); |
|
|
| const handleUpdateConnection = useCallback( |
| async (formData) => { |
| if (!selectedConnection?.id) return; |
| const connectionId = selectedConnection.id; |
| const provider = selectedConnection.provider; |
| try { |
| const res = await fetch(`/api/providers/${connectionId}`, { |
| method: "PUT", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify(formData), |
| }); |
| if (res.ok) { |
| await fetchConnections(); |
| setShowEditModal(false); |
| setSelectedConnection(null); |
| if (USAGE_SUPPORTED_PROVIDERS.includes(provider)) { |
| await fetchQuota(connectionId, provider); |
| } |
| } |
| } catch (error) { |
| console.error("Error saving connection:", error); |
| } |
| }, |
| [selectedConnection, fetchConnections, fetchQuota], |
| ); |
|
|
| useEffect(() => { |
| let cancelled = false; |
| fetch("/api/proxy-pools?isActive=true", { cache: "no-store" }) |
| .then((res) => res.json()) |
| .then((data) => { |
| if (!cancelled && data?.proxyPools) { |
| setProxyPools(data.proxyPools); |
| } |
| }) |
| .catch(() => {}); |
| return () => { |
| cancelled = true; |
| }; |
| }, []); |
|
|
| const refreshAll = useCallback(async () => { |
| if (refreshingAll) return; |
|
|
| setRefreshingAll(true); |
| setCountdown(60); |
|
|
| try { |
| const visibleConnections = await fetchConnections(page); |
|
|
| setLoading(buildLoadingState(visibleConnections)); |
| setErrors((prev) => |
| filterQuotaStateByConnections(prev, visibleConnections), |
| ); |
| setQuotaData((prev) => |
| filterQuotaStateByConnections(prev, visibleConnections), |
| ); |
|
|
| await Promise.all( |
| visibleConnections.map((conn) => fetchQuota(conn.id, conn.provider)), |
| ); |
|
|
| setLastUpdated(new Date()); |
| } catch (error) { |
| console.error("Error refreshing all providers:", error); |
| } finally { |
| setRefreshingAll(false); |
| } |
| }, [refreshingAll, fetchConnections, fetchQuota, page]); |
|
|
| useEffect(() => { |
| const initializeData = async () => { |
| setConnectionsLoading(true); |
| const visibleConnections = await fetchConnections(page); |
| setConnectionsLoading(false); |
|
|
| |
| setLoading(buildLoadingState(visibleConnections)); |
| setErrors((prev) => |
| filterQuotaStateByConnections(prev, visibleConnections), |
| ); |
| setQuotaData((prev) => |
| filterQuotaStateByConnections(prev, visibleConnections), |
| ); |
|
|
| await Promise.all( |
| visibleConnections.map((conn) => fetchQuota(conn.id, conn.provider)), |
| ); |
| setLastUpdated(new Date()); |
| }; |
|
|
| initializeData(); |
| }, [fetchConnections, fetchQuota, page]); |
|
|
| useEffect(() => { |
| if (typeof window === "undefined") return; |
| const stored = window.localStorage.getItem(AUTO_REFRESH_STORAGE_KEY); |
| setAutoRefresh(stored === null ? true : stored === "true"); |
| setHasHydratedAutoRefresh(true); |
| }, []); |
|
|
| |
| useEffect(() => { |
| if (typeof window === "undefined" || !hasHydratedAutoRefresh) return; |
| window.localStorage.setItem(AUTO_REFRESH_STORAGE_KEY, String(autoRefresh)); |
| }, [autoRefresh, hasHydratedAutoRefresh]); |
|
|
| |
| useEffect(() => { |
| if (!hasHydratedAutoRefresh || !autoRefresh) { |
| if (intervalRef.current) { |
| clearInterval(intervalRef.current); |
| intervalRef.current = null; |
| } |
| if (countdownRef.current) { |
| clearInterval(countdownRef.current); |
| countdownRef.current = null; |
| } |
| return; |
| } |
|
|
| |
| intervalRef.current = setInterval(() => { |
| refreshAll(); |
| }, REFRESH_INTERVAL_MS); |
|
|
| |
| countdownRef.current = setInterval(() => { |
| setCountdown((prev) => { |
| if (prev <= 1) return 60; |
| return prev - 1; |
| }); |
| }, 1000); |
|
|
| return () => { |
| if (intervalRef.current) clearInterval(intervalRef.current); |
| if (countdownRef.current) clearInterval(countdownRef.current); |
| }; |
| }, [autoRefresh, refreshAll, hasHydratedAutoRefresh]); |
|
|
| |
| useEffect(() => { |
| const handleVisibilityChange = () => { |
| if (document.hidden) { |
| if (intervalRef.current) { |
| clearInterval(intervalRef.current); |
| intervalRef.current = null; |
| } |
| if (countdownRef.current) { |
| clearInterval(countdownRef.current); |
| countdownRef.current = null; |
| } |
| } else if (autoRefresh && hasHydratedAutoRefresh) { |
| |
| intervalRef.current = setInterval(refreshAll, REFRESH_INTERVAL_MS); |
| countdownRef.current = setInterval(() => { |
| setCountdown((prev) => (prev <= 1 ? 60 : prev - 1)); |
| }, 1000); |
| } |
| }; |
|
|
| document.addEventListener("visibilitychange", handleVisibilityChange); |
| return () => { |
| document.removeEventListener("visibilitychange", handleVisibilityChange); |
| }; |
| }, [autoRefresh, refreshAll, hasHydratedAutoRefresh]); |
|
|
| const sortedConnections = useMemo( |
| () => |
| sortVisibleConnections( |
| connections, |
| quotaData, |
| expiringFirst, |
| providerFilter, |
| quotaSortMode, |
| ), |
| [connections, quotaData, expiringFirst, providerFilter, quotaSortMode], |
| ); |
|
|
| |
| const isConnectionDepleted = (conn) => { |
| const quotas = quotaData[conn.id]?.quotas; |
| if (!quotas?.length) return false; |
| return quotas.some((q) => { |
| if (!q.total || q.total <= 0) return false; |
| return calculatePercentage(q.used, q.total) <= DEPLETED_QUOTA_THRESHOLD; |
| }); |
| }; |
|
|
| const bulkSetActive = useCallback( |
| async (targetIds, isActive) => { |
| if (!targetIds.length || bulkToggling) return; |
| setBulkToggling(true); |
| try { |
| await Promise.all( |
| targetIds.map((id) => |
| fetch(`/api/providers/${id}`, { |
| method: "PUT", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ isActive }), |
| }), |
| ), |
| ); |
| await reconcileConnectionsPage(fetchConnections, page); |
| } catch (error) { |
| console.error("Error bulk toggling connections:", error); |
| } finally { |
| setBulkToggling(false); |
| } |
| }, |
| [bulkToggling, fetchConnections, page], |
| ); |
|
|
| const handleDisableDepleted = () => { |
| const ids = sortedConnections |
| .filter((c) => (c.isActive ?? true) && isConnectionDepleted(c)) |
| .map((c) => c.id); |
| bulkSetActive(ids, false); |
| }; |
|
|
| const handleEnableAvailable = () => { |
| const ids = sortedConnections |
| .filter((c) => !(c.isActive ?? true) && !isConnectionDepleted(c)) |
| .map((c) => c.id); |
| bulkSetActive(ids, true); |
| }; |
|
|
| const selectedProviderLabel = |
| providerFilter === "all" ? "All providers" : providerFilter; |
| const hasEligibleConnections = totals.eligibleConnections > 0; |
| const hasVisibleConnections = sortedConnections.length > 0; |
| const emptyState = getConnectionsEmptyMessage( |
| totals, |
| providerFilter, |
| accountFilter, |
| ); |
| const connectionsPageSummary = getConnectionsPaginationSummary(pagination); |
| const isCustomPageSize = !ACCOUNT_PAGE_SIZE_OPTIONS.includes(pageSize); |
| const pageSizeLabel = getPageSizeLabel(pageSize, isCustomPageSize); |
|
|
| if (!connectionsLoading && !hasEligibleConnections) { |
| return ( |
| <Card padding="lg"> |
| <div className="text-center py-12"> |
| <span className="material-symbols-outlined text-[64px] text-text-muted opacity-20"> |
| cloud_off |
| </span> |
| <h3 className="mt-4 text-lg font-semibold text-text-primary"> |
| No Providers Connected |
| </h3> |
| <p className="mt-2 text-sm text-text-muted max-w-md mx-auto"> |
| Connect to providers with OAuth to track your API quota limits and |
| usage. |
| </p> |
| </div> |
| </Card> |
| ); |
| } |
|
|
| if (!connectionsLoading && !hasVisibleConnections) { |
| return ( |
| <Card padding="lg"> |
| <div className="text-center py-12"> |
| <span className="material-symbols-outlined text-[64px] text-text-muted opacity-20"> |
| {emptyState.icon} |
| </span> |
| <h3 className="mt-4 text-lg font-semibold text-text-primary"> |
| {emptyState.title} |
| </h3> |
| <p className="mt-2 text-sm text-text-muted max-w-md mx-auto"> |
| {emptyState.description} |
| </p> |
| </div> |
| </Card> |
| ); |
| } |
|
|
| return ( |
| <div className="space-y-6"> |
| {/* Header Controls */} |
| <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-end"> |
| <div className="flex flex-wrap items-center gap-1.5"> |
| <div className="relative"> |
| <button |
| type="button" |
| onClick={() => setProviderMenuOpen((prev) => !prev)} |
| className="flex h-8 items-center justify-between gap-1 rounded-lg border border-black/10 bg-black/[0.02] px-2 text-xs text-text-primary transition-colors hover:bg-black/5 dark:border-white/10 dark:bg-white/[0.03] dark:hover:bg-white/10" |
| aria-haspopup="menu" |
| aria-expanded={providerMenuOpen} |
| title="Filter quota providers" |
| > |
| <span className="flex min-w-0 items-center gap-1.5"> |
| {providerFilter === "all" ? ( |
| <span className="material-symbols-outlined text-[14px] text-text-muted"> |
| apps |
| </span> |
| ) : ( |
| <ProviderIcon |
| src={`/providers/${providerFilter}.png`} |
| alt={providerFilter} |
| size={18} |
| className="size-[18px] rounded object-contain" |
| fallbackText={providerFilter.slice(0, 2).toUpperCase()} |
| /> |
| )} |
| <span className="truncate capitalize hidden lg:inline"> |
| {selectedProviderLabel} |
| </span> |
| </span> |
| <span className="material-symbols-outlined text-[14px] text-text-muted"> |
| expand_more |
| </span> |
| </button> |
| |
| {providerMenuOpen && ( |
| <> |
| <button |
| type="button" |
| className="fixed inset-0 z-30 bg-transparent" |
| aria-label="Close provider filter" |
| onClick={() => setProviderMenuOpen(false)} |
| /> |
| <div className="absolute left-0 z-40 mt-2 w-64 overflow-hidden rounded-2xl border border-black/10 bg-surface/95 p-1.5 shadow-xl shadow-black/10 backdrop-blur dark:border-white/10 dark:bg-surface/95 sm:w-72"> |
| <button |
| type="button" |
| onClick={() => { |
| if (shouldResetPage(providerFilter, "all")) { |
| setPage(1); |
| } |
| setProviderFilter("all"); |
| setProviderMenuOpen(false); |
| }} |
| className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors ${providerFilter === "all" ? "bg-primary/10 text-primary" : "text-text-primary hover:bg-black/5 dark:hover:bg-white/10"}`} |
| > |
| <span className="material-symbols-outlined text-[22px]"> |
| apps |
| </span> |
| <span className="font-medium">All providers</span> |
| {providerFilter === "all" && ( |
| <span className="material-symbols-outlined ml-auto text-[20px]"> |
| check |
| </span> |
| )} |
| </button> |
| <div className="my-1 h-px bg-black/10 dark:bg-white/10" /> |
| <div className="max-h-72 overflow-y-auto pr-1"> |
| {providerOptions.map((provider) => ( |
| <button |
| key={provider} |
| type="button" |
| onClick={() => { |
| if (shouldResetPage(providerFilter, provider)) { |
| setPage(1); |
| } |
| setProviderFilter(provider); |
| setProviderMenuOpen(false); |
| }} |
| className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors ${providerFilter === provider ? "bg-primary/10 text-primary" : "text-text-primary hover:bg-black/5 dark:hover:bg-white/10"}`} |
| > |
| <ProviderIcon |
| src={`/providers/${provider}.png`} |
| alt={provider} |
| size={24} |
| className="size-6 rounded-md object-contain" |
| fallbackText={provider.slice(0, 2).toUpperCase()} |
| /> |
| <span className="font-medium capitalize"> |
| {provider} |
| </span> |
| {providerFilter === provider && ( |
| <span className="material-symbols-outlined ml-auto text-[20px]"> |
| check |
| </span> |
| )} |
| </button> |
| ))} |
| </div> |
| </div> |
| </> |
| )} |
| </div> |
| <select |
| value={accountFilter} |
| onChange={(event) => { |
| const nextValue = event.target.value; |
| if (shouldResetPage(accountFilter, nextValue)) { |
| setPage(1); |
| } |
| setAccountFilter(nextValue); |
| }} |
| className="h-8 rounded-lg border border-black/10 bg-black/[0.02] px-2 text-xs text-text-primary outline-none transition-colors hover:bg-black/5 dark:border-white/10 dark:bg-white/[0.03] dark:hover:bg-white/10" |
| aria-label="Filter accounts by status" |
| > |
| {ACCOUNT_FILTER_OPTIONS.map((option) => ( |
| <option key={option.value} value={option.value}> |
| {option.label} |
| </option> |
| ))} |
| </select> |
| |
| {providerFilter === "codex" && ( |
| <select |
| value={quotaSortMode} |
| onChange={(event) => setQuotaSortMode(event.target.value)} |
| className="h-8 rounded-lg border border-black/10 bg-black/[0.02] px-2 text-xs text-text-primary outline-none transition-colors hover:bg-black/5 dark:border-white/10 dark:bg-white/[0.03] dark:hover:bg-white/10" |
| aria-label="Sort Codex quotas by remaining" |
| > |
| {QUOTA_SORT_OPTIONS.map((option) => ( |
| <option key={option.value} value={option.value}> |
| {option.label} |
| </option> |
| ))} |
| </select> |
| )} |
| |
| <button |
| type="button" |
| onClick={() => setExpiringFirst((prev) => !prev)} |
| aria-pressed={expiringFirst} |
| className={`flex h-8 shrink-0 items-center gap-1 rounded-lg border px-2 text-xs transition-colors ${expiringFirst ? "border-amber-500/40 bg-amber-500/10 text-amber-500" : "border-black/10 text-text-primary hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/5"}`} |
| title="Sort accounts by earliest quota reset time" |
| > |
| <span className="material-symbols-outlined text-[14px]"> |
| hourglass_top |
| </span> |
| <span className="hidden sm:inline">Expiring first</span> |
| </button> |
| |
| {/* Bulk: disable depleted */} |
| <button |
| type="button" |
| onClick={handleDisableDepleted} |
| disabled={bulkToggling} |
| className="flex h-8 shrink-0 items-center gap-1 rounded-lg border border-red-500/30 px-2 text-xs text-red-500 transition-colors hover:bg-red-500/10 disabled:opacity-50" |
| title="Disable connections with depleted quota on the current page" |
| > |
| <span className="material-symbols-outlined text-[14px]">block</span> |
| <span className="hidden sm:inline">Turn off Empty</span> |
| </button> |
| |
| {/* Bulk: enable available */} |
| <button |
| type="button" |
| onClick={handleEnableAvailable} |
| disabled={bulkToggling} |
| className="flex h-8 shrink-0 items-center gap-1 rounded-lg border border-emerald-500/30 px-2 text-xs text-emerald-500 transition-colors hover:bg-emerald-500/10 disabled:opacity-50" |
| title="Enable connections that still have quota on the current page" |
| > |
| <span className="material-symbols-outlined text-[14px]"> |
| check_circle |
| </span> |
| <span className="hidden sm:inline">Turn on Available</span> |
| </button> |
| |
| {/* Auto-refresh toggle */} |
| <button |
| onClick={() => setAutoRefresh((prev) => !prev)} |
| className="flex h-8 shrink-0 items-center gap-1 rounded-lg border border-black/10 px-2 text-xs transition-colors hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/5" |
| title={autoRefresh ? "Disable auto-refresh" : "Enable auto-refresh"} |
| > |
| <span |
| className={`material-symbols-outlined text-[14px] ${ |
| autoRefresh ? "text-primary" : "text-text-muted" |
| }`} |
| > |
| {autoRefresh ? "toggle_on" : "toggle_off"} |
| </span> |
| <span className="hidden text-text-primary sm:inline"> |
| Auto-refresh |
| </span> |
| {autoRefresh && ( |
| <span className="text-[10px] text-text-muted tabular-nums"> |
| ({countdown}s) |
| </span> |
| )} |
| </button> |
| |
| {/* Refresh all button */} |
| <button |
| type="button" |
| onClick={refreshAll} |
| disabled={refreshingAll} |
| className="flex h-8 shrink-0 items-center gap-1 rounded-lg border border-black/10 px-2 text-xs text-text-primary transition-colors hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/5 disabled:opacity-50" |
| title="Refresh all" |
| > |
| <span |
| className={`material-symbols-outlined text-[14px] ${refreshingAll ? "animate-spin" : ""}`} |
| > |
| refresh |
| </span> |
| </button> |
| </div> |
| </div> |
|
|
| {} |
| {expiringFirst && ( |
| <div className="rounded-xl border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300"> |
| Expiring-first currently reorders accounts inside the current page. |
| Cross-page ordering still follows backend pagination. |
| </div> |
| )} |
|
|
| <div className="grid grid-cols-1 md:grid-cols-2 gap-3"> |
| {sortedConnections.map((conn) => { |
| const quota = quotaData[conn.id]; |
| const isLoading = loading[conn.id]; |
| const error = errors[conn.id]; |
|
|
| |
| const isInactive = conn.isActive === false; |
| const rowBusy = deletingId === conn.id || togglingId === conn.id; |
|
|
| return ( |
| <Card |
| key={conn.id} |
| padding="none" |
| className={`min-w-0 ${isInactive ? "opacity-60" : ""}`} |
| > |
| <div className="px-3 py-2 border-b border-black/10 dark:border-white/10"> |
| <div className="flex items-center justify-between gap-2"> |
| <div className="flex items-center gap-2 min-w-0"> |
| <div className="w-8 h-8 shrink-0 rounded-md flex items-center justify-center overflow-hidden"> |
| <ProviderIcon |
| src={`/providers/${conn.provider}.png`} |
| alt={conn.provider} |
| size={32} |
| className="object-contain" |
| fallbackText={ |
| conn.provider?.slice(0, 2).toUpperCase() || "PR" |
| } |
| /> |
| </div> |
| <div className="min-w-0"> |
| <h3 className="text-sm font-semibold text-text-primary capitalize truncate"> |
| {conn.provider} |
| </h3> |
| {getConnectionLabel(conn) ? ( |
| <p className="text-xs text-text-muted truncate"> |
| {getConnectionLabel(conn)} |
| </p> |
| ) : null} |
| </div> |
| </div> |
| |
| <div className="flex items-center gap-1 shrink-0"> |
| <button |
| type="button" |
| onClick={() => refreshProvider(conn.id, conn.provider)} |
| disabled={isLoading || rowBusy} |
| aria-label="Refresh quota" |
| className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50" |
| title="Refresh quota" |
| > |
| <span |
| className={`material-symbols-outlined text-[18px] text-text-muted ${isLoading ? "animate-spin" : ""}`} |
| > |
| refresh |
| </span> |
| </button> |
| <button |
| type="button" |
| onClick={() => { |
| setSelectedConnection(conn); |
| setShowEditModal(true); |
| }} |
| disabled={rowBusy} |
| aria-label="Edit connection" |
| className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary transition-colors disabled:opacity-50" |
| title="Edit connection" |
| > |
| <span className="material-symbols-outlined text-[18px]"> |
| edit |
| </span> |
| </button> |
| <button |
| type="button" |
| onClick={() => handleDeleteConnection(conn.id)} |
| disabled={rowBusy} |
| aria-label="Delete connection" |
| className="p-1.5 rounded-lg hover:bg-red-500/10 text-red-500 transition-colors disabled:opacity-50" |
| title="Delete connection" |
| > |
| <span |
| className={`material-symbols-outlined text-[18px] ${deletingId === conn.id ? "animate-pulse" : ""}`} |
| > |
| delete |
| </span> |
| </button> |
| <div |
| className="inline-flex items-center pl-0.5" |
| title={ |
| (conn.isActive ?? true) |
| ? "Disable connection" |
| : "Enable connection" |
| } |
| > |
| <Toggle |
| size="sm" |
| checked={conn.isActive ?? true} |
| disabled={rowBusy} |
| onChange={(nextActive) => |
| handleToggleConnectionActive(conn.id, nextActive) |
| } |
| /> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| <div className="px-2 py-1.5"> |
| {isLoading ? ( |
| <div className="text-center py-5 text-text-muted"> |
| <span className="material-symbols-outlined text-[28px] animate-spin"> |
| progress_activity |
| </span> |
| </div> |
| ) : error ? ( |
| <div className="text-center py-5"> |
| <span className="material-symbols-outlined text-[28px] text-red-500"> |
| error |
| </span> |
| <p className="mt-1.5 text-xs text-text-muted">{error}</p> |
| </div> |
| ) : quota?.message ? ( |
| <div className="text-center py-5"> |
| <p className="text-xs text-text-muted">{quota.message}</p> |
| </div> |
| ) : ( |
| <QuotaTable |
| quotas={quota?.quotas} |
| compact |
| sortMode="default" |
| showSortLabel={ |
| conn.provider === "codex" && quotaSortMode !== "default" |
| } |
| /> |
| )} |
| </div> |
| </Card> |
| ); |
| })} |
| </div> |
|
|
| <div className="rounded-xl border border-black/10 bg-black/[0.02] px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]"> |
| <div className="flex flex-wrap items-center justify-between gap-2"> |
| <span className="text-xs text-text-muted">{connectionsPageSummary}</span> |
| <div className="flex flex-wrap items-center gap-2"> |
| <select |
| value={isCustomPageSize ? "custom" : String(pageSize)} |
| onChange={(event) => { |
| const nextValue = event.target.value; |
| if (nextValue === "custom") return; |
| const nextPageSize = Number.parseInt(nextValue, 10); |
| if (Number.isFinite(nextPageSize)) { |
| setPage(1); |
| setPageSize(nextPageSize); |
| setCustomPageSizeInput(String(nextPageSize)); |
| } |
| }} |
| className="h-8 rounded-lg border border-black/10 bg-black/[0.02] px-2 text-xs text-text-primary outline-none transition-colors hover:bg-black/5 dark:border-white/10 dark:bg-white/[0.03] dark:hover:bg-white/10" |
| aria-label="Accounts per page" |
| > |
| {ACCOUNT_PAGE_SIZE_OPTIONS.map((option) => ( |
| <option key={option} value={String(option)}> |
| {option} / page |
| </option> |
| ))} |
| <option value="custom">Custom</option> |
| </select> |
| <input |
| type="number" |
| min="1" |
| max={String(ACCOUNT_PAGE_SIZE_MAX)} |
| inputMode="numeric" |
| value={customPageSizeInput} |
| onChange={(event) => setCustomPageSizeInput(event.target.value)} |
| onBlur={() => { |
| const parsedValue = Number.parseInt(customPageSizeInput, 10); |
| if (!Number.isFinite(parsedValue)) { |
| setCustomPageSizeInput(String(pageSize)); |
| return; |
| } |
| const nextPageSize = Math.min(ACCOUNT_PAGE_SIZE_MAX, Math.max(1, parsedValue)); |
| setPage(1); |
| setPageSize(nextPageSize); |
| setCustomPageSizeInput(String(nextPageSize)); |
| }} |
| onKeyDown={(event) => { |
| if (event.key !== "Enter") return; |
| const parsedValue = Number.parseInt(customPageSizeInput, 10); |
| if (!Number.isFinite(parsedValue)) { |
| setCustomPageSizeInput(String(pageSize)); |
| return; |
| } |
| const nextPageSize = Math.min(ACCOUNT_PAGE_SIZE_MAX, Math.max(1, parsedValue)); |
| setPage(1); |
| setPageSize(nextPageSize); |
| setCustomPageSizeInput(String(nextPageSize)); |
| }} |
| className="h-8 w-20 rounded-lg border border-black/10 bg-black/[0.02] px-2 text-xs text-text-primary outline-none transition-colors hover:bg-black/5 dark:border-white/10 dark:bg-white/[0.03] dark:hover:bg-white/10" |
| aria-label="Custom accounts per page" |
| placeholder="Custom" |
| /> |
| <span className="text-xs text-text-muted">Page {pagination.page} / {pagination.totalPages}</span> |
| </div> |
| <div className="flex items-center gap-1.5"> |
| <button |
| type="button" |
| onClick={() => setPage(1)} |
| disabled={ |
| pagination.page <= 1 || connectionsLoading || refreshingAll |
| } |
| className="flex h-8 items-center rounded-lg border border-black/10 px-3 text-xs text-text-primary transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:hover:bg-white/5" |
| > |
| First Page |
| </button> |
| <button |
| type="button" |
| onClick={() => |
| setPage((currentPage) => Math.max(1, currentPage - 1)) |
| } |
| disabled={ |
| pagination.page <= 1 || connectionsLoading || refreshingAll |
| } |
| className="flex h-8 w-8 items-center justify-center rounded-lg border border-black/10 text-text-primary transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:hover:bg-white/5" |
| aria-label="Previous accounts page" |
| > |
| <span className="material-symbols-outlined text-[16px]"> |
| chevron_left |
| </span> |
| </button> |
| <button |
| type="button" |
| onClick={() => |
| setPage((currentPage) => |
| Math.min(pagination.totalPages, currentPage + 1), |
| ) |
| } |
| disabled={ |
| pagination.page >= pagination.totalPages || |
| connectionsLoading || |
| refreshingAll |
| } |
| className="flex h-8 w-8 items-center justify-center rounded-lg border border-black/10 text-text-primary transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:hover:bg-white/5" |
| aria-label="Next accounts page" |
| > |
| <span className="material-symbols-outlined text-[16px]"> |
| chevron_right |
| </span> |
| </button> |
| <button |
| type="button" |
| onClick={() => setPage(pagination.totalPages)} |
| disabled={ |
| pagination.page >= pagination.totalPages || |
| connectionsLoading || |
| refreshingAll |
| } |
| className="flex h-8 items-center rounded-lg border border-black/10 px-3 text-xs text-text-primary transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:hover:bg-white/5" |
| > |
| Last Page |
| </button> |
| </div> |
| </div> |
| </div> |
|
|
| <EditConnectionModal |
| isOpen={showEditModal} |
| connection={selectedConnection} |
| proxyPools={proxyPools} |
| onSave={handleUpdateConnection} |
| onClose={() => { |
| setShowEditModal(false); |
| setSelectedConnection(null); |
| }} |
| /> |
| </div> |
| ); |
| } |
|
|