diff --git a/open-sse/utils/streamHandler.js b/open-sse/utils/streamHandler.js
index 6a501f5c..f3fc50c8 100644
--- a/open-sse/utils/streamHandler.js
+++ b/open-sse/utils/streamHandler.js
@@ -113,10 +113,22 @@ export function createDisconnectAwareStream(transformStream, streamController) {
}
controller.enqueue(value);
} catch (error) {
+ const wasConnected = streamController.isConnected();
streamController.handleError(error);
reader.cancel().catch(() => {});
writer.abort().catch(() => {});
- controller.error(error);
+
+ if (!wasConnected || error.name === "AbortError" || error.message?.includes("aborted")) {
+ try {
+ controller.close();
+ } catch (e) {
+ // Stream might already be closed or cancelled
+ }
+ } else {
+ try {
+ controller.error(error);
+ } catch (e) { /* already closed */ }
+ }
}
},
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js
index dbf140d7..e6e7b611 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js
+++ b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js
@@ -5,7 +5,7 @@ import PropTypes from "prop-types";
import { Badge, Toggle } from "@/shared/components";
import CooldownTimer from "./CooldownTimer";
-export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete }) {
+export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null }) {
const [showProxyDropdown, setShowProxyDropdown] = useState(false);
const [updatingProxy, setUpdatingProxy] = useState(false);
const proxyDropdownRef = useRef(null);
@@ -114,6 +114,23 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
return "default";
};
+ const getOneByOneVariant = () => {
+ if (!oneByOneStatus) return "default";
+ if (oneByOneStatus.state === "success") return "success";
+ if (oneByOneStatus.state === "failed") return "error";
+ if (oneByOneStatus.state === "testing") return "primary";
+ return "default";
+ };
+
+ const getOneByOneLabel = () => {
+ if (!oneByOneStatus) return null;
+ if (oneByOneStatus.state === "queued") return "queued";
+ if (oneByOneStatus.state === "testing") return "testing";
+ if (oneByOneStatus.state === "success") return "success";
+ if (oneByOneStatus.state === "failed") return oneByOneStatus.error ? `failed: ${oneByOneStatus.error}` : "failed";
+ return null;
+ };
+
return (
@@ -161,6 +178,11 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
{connection.globalPriority && (
Auto: {connection.globalPriority}
)}
+ {getOneByOneLabel() && (
+
+ {getOneByOneLabel()}
+
+ )}
{hasAnyProxy && (
@@ -266,4 +288,8 @@ ConnectionRow.propTypes = {
onUpdateProxy: PropTypes.func,
onEdit: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
+ oneByOneStatus: PropTypes.shape({
+ state: PropTypes.string,
+ error: PropTypes.string,
+ }),
};
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js
index bccf1c7c..842096df 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/page.js
+++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect, useCallback } from "react";
+import { useState, useEffect, useCallback, useRef } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
@@ -17,6 +17,12 @@ import AddApiKeyModal from "./AddApiKeyModal";
import EditCompatibleNodeModal from "./EditCompatibleNodeModal";
import AddCustomModelModal from "./AddCustomModelModal";
+const ONE_BY_ONE_DELAY_MS = 1000;
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
export default function ProviderDetailPage() {
const params = useParams();
const router = useRouter();
@@ -50,6 +56,12 @@ export default function ProviderDetailPage() {
const [disabledModelIds, setDisabledModelIds] = useState([]);
const [confirmState, setConfirmState] = useState(null);
const [showAgRiskModal, setShowAgRiskModal] = useState(false);
+ const [oneByOneRunning, setOneByOneRunning] = useState(false);
+ const [oneByOneStopping, setOneByOneStopping] = useState(false);
+ const [oneByOneCurrentConnectionId, setOneByOneCurrentConnectionId] = useState(null);
+ const [oneByOneResults, setOneByOneResults] = useState({});
+ const [oneByOneSummary, setOneByOneSummary] = useState(null);
+ const stopOneByOneRef = useRef(false);
const { copied, copy } = useCopyToClipboard();
const AG_RISK_STORAGE_KEY = "ag_risk_confirmed";
@@ -397,6 +409,98 @@ export default function ProviderDetailPage() {
}
};
+ const handleRunOneByOneTest = async () => {
+ if (oneByOneRunning || connections.length === 0) return;
+
+ const queuedState = Object.fromEntries(
+ connections.map((connection) => [connection.id, { state: "queued", error: null }]),
+ );
+
+ stopOneByOneRef.current = false;
+ setOneByOneRunning(true);
+ setOneByOneStopping(false);
+ setOneByOneCurrentConnectionId(null);
+ setOneByOneResults(queuedState);
+ setOneByOneSummary({ total: connections.length, completed: 0, passed: 0, failed: 0, stopped: false });
+
+ let passed = 0;
+ let failed = 0;
+
+ try {
+ for (let index = 0; index < connections.length; index += 1) {
+ if (stopOneByOneRef.current) {
+ setOneByOneSummary({
+ total: connections.length,
+ completed: index,
+ passed,
+ failed,
+ stopped: true,
+ });
+ break;
+ }
+
+ const connection = connections[index];
+ setOneByOneCurrentConnectionId(connection.id);
+ setOneByOneResults((prev) => ({
+ ...prev,
+ [connection.id]: { state: "testing", error: null },
+ }));
+
+ try {
+ const res = await fetch(`/api/providers/${connection.id}/test`, { method: "POST" });
+ const data = await res.json();
+ const valid = !!data.valid;
+
+ if (valid) {
+ passed += 1;
+ } else {
+ failed += 1;
+ }
+
+ setOneByOneResults((prev) => ({
+ ...prev,
+ [connection.id]: {
+ state: valid ? "success" : "failed",
+ error: valid ? null : (data.error || null),
+ },
+ }));
+ } catch (error) {
+ failed += 1;
+ setOneByOneResults((prev) => ({
+ ...prev,
+ [connection.id]: {
+ state: "failed",
+ error: error.message || "Test failed",
+ },
+ }));
+ }
+
+ setOneByOneSummary({
+ total: connections.length,
+ completed: index + 1,
+ passed,
+ failed,
+ stopped: false,
+ });
+
+ if (index < connections.length - 1) {
+ await sleep(ONE_BY_ONE_DELAY_MS);
+ }
+ }
+ } finally {
+ setOneByOneCurrentConnectionId(null);
+ setOneByOneRunning(false);
+ setOneByOneStopping(false);
+ stopOneByOneRef.current = false;
+ }
+ };
+
+ const handleStopOneByOneTest = () => {
+ if (!oneByOneRunning) return;
+ stopOneByOneRef.current = true;
+ setOneByOneStopping(true);
+ };
+
const handleDelete = async (id) => {
setConfirmState({
title: "Delete Connection",
@@ -646,6 +750,7 @@ export default function ProviderDetailPage() {
setShowEditModal(true);
}}
onDelete={() => handleDelete(conn.id)}
+ oneByOneStatus={oneByOneResults[conn.id] || null}
/>
@@ -1063,6 +1168,30 @@ export default function ProviderDetailPage() {
Apply Proxy
)}
+ {connections.length > 0 && (
+ <>
+
+ {oneByOneRunning && (
+
+ )}
+ >
+ )}
{/* Thinking config */}
{/* {thinkingConfig && (
@@ -1147,6 +1276,22 @@ export default function ProviderDetailPage() {
) : (
<>
+ {oneByOneSummary && (
+
+
+ Total: {oneByOneSummary.total}
+ Completed: {oneByOneSummary.completed}
+ Passed: {oneByOneSummary.passed}
+ Failed: {oneByOneSummary.failed}
+ {oneByOneSummary.stopped && (
+ Stopped
+ )}
+ {oneByOneRunning && oneByOneCurrentConnectionId && (
+ Running: {connections.find((conn) => conn.id === oneByOneCurrentConnectionId)?.name || oneByOneCurrentConnectionId}
+ )}
+
+
+ )}
{connectionsList}
{!isCompatible && (
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js
index f1e2cbc8..765d7e91 100644
--- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js
+++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js
@@ -1,20 +1,23 @@
"use client";
-import { formatResetTime, calculatePercentage } from "./utils";
+import { useEffect, useMemo, useState } from "react";
+import { formatResetTime, getRemainingPercentage } from "./utils";
+
+const PAGE_SIZE = 10;
/**
* Format reset time display (Today, 12:00 PM)
*/
function formatResetTimeDisplay(resetTime) {
if (!resetTime) return null;
-
+
try {
const date = new Date(resetTime);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
-
+
let dayStr = "";
if (date >= today && date < tomorrow) {
dayStr = "Today";
@@ -23,13 +26,13 @@ function formatResetTimeDisplay(resetTime) {
} else {
dayStr = date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
-
- const timeStr = date.toLocaleTimeString("en-US", {
- hour: "numeric",
+
+ const timeStr = date.toLocaleTimeString("en-US", {
+ hour: "numeric",
minute: "2-digit",
- hour12: true
+ hour12: true,
});
-
+
return `${dayStr}, ${timeStr}`;
} catch {
return null;
@@ -45,127 +48,212 @@ function getColorClasses(remainingPercentage) {
text: "text-green-600 dark:text-green-400",
bg: "bg-green-500",
bgLight: "bg-green-500/10",
- emoji: "🟢"
+ emoji: "🟢",
};
}
-
+
if (remainingPercentage >= 30) {
return {
text: "text-yellow-600 dark:text-yellow-400",
bg: "bg-yellow-500",
bgLight: "bg-yellow-500/10",
- emoji: "🟡"
+ emoji: "🟡",
};
}
-
- // 0-29% including 0% (out of quota) - show red
+
return {
text: "text-red-600 dark:text-red-400",
bg: "bg-red-500",
bgLight: "bg-red-500/10",
- emoji: "🔴"
+ emoji: "🔴",
};
}
+function sortQuotas(quotas, sortMode) {
+ if (sortMode === "remaining-asc") {
+ return [...quotas].sort((a, b) => a.remaining - b.remaining || a.name.localeCompare(b.name));
+ }
+
+ if (sortMode === "remaining-desc") {
+ return [...quotas].sort((a, b) => b.remaining - a.remaining || a.name.localeCompare(b.name));
+ }
+
+ return quotas;
+}
+
/**
* Quota Table Component - Table-based display for quota data
*/
-export default function QuotaTable({ quotas = [], compact = false }) {
+export default function QuotaTable({
+ quotas = [],
+ compact = false,
+ sortMode = "default",
+ showSortLabel = false,
+}) {
+ const [page, setPage] = useState(1);
+
+ const normalizedQuotas = useMemo(
+ () => quotas.map((quota, index) => ({
+ ...quota,
+ index,
+ remaining: getRemainingPercentage(quota),
+ })),
+ [quotas],
+ );
+
+ const sortedQuotas = useMemo(
+ () => sortQuotas(normalizedQuotas, sortMode),
+ [normalizedQuotas, sortMode],
+ );
+
+ const totalPages = Math.max(1, Math.ceil(sortedQuotas.length / PAGE_SIZE));
+
+ useEffect(() => {
+ setPage(1);
+ }, [sortMode, quotas]);
+
+ useEffect(() => {
+ setPage((currentPage) => Math.min(currentPage, totalPages));
+ }, [totalPages]);
+
if (!quotas || quotas.length === 0) {
return null;
}
+ const currentPageRows = sortedQuotas.slice(
+ (page - 1) * PAGE_SIZE,
+ page * PAGE_SIZE,
+ );
+ const pageStart = sortedQuotas.length === 0 ? 0 : (page - 1) * PAGE_SIZE + 1;
+ const pageEnd = Math.min(page * PAGE_SIZE, sortedQuotas.length);
+
const cellPad = compact ? "py-1 px-1.5" : "py-2 px-3";
const nameText = compact ? "text-[11px]" : "text-sm";
const resetPrimary = compact ? "text-[11px]" : "text-sm";
const resetSecondary = compact ? "text-[10px] leading-tight" : "text-xs";
+ const sortLabel = "Sorted by account remaining";
return (
-
-
-
- {quotas.map((quota, index) => {
- const remaining = quota.remainingPercentage !== undefined
- ? Math.round(quota.remainingPercentage)
- : calculatePercentage(quota.used, quota.total);
-
- const colors = getColorClasses(remaining);
- const countdown = formatResetTime(quota.resetAt);
- const resetDisplay = formatResetTimeDisplay(quota.resetAt);
+
+
+
+ {sortedQuotas.length} quota{sortedQuotas.length > 1 ? "s" : ""}
+
+ {showSortLabel && (
+
+ {sortLabel}
+
+ )}
+
- return (
-
- {/* Model Name with Status Emoji */}
- |
-
- {colors.emoji}
-
- {quota.name}
-
-
- |
+
+
+
+ {currentPageRows.map((quota) => {
+ const colors = getColorClasses(quota.remaining);
+ const countdown = formatResetTime(quota.resetAt);
+ const resetDisplay = formatResetTimeDisplay(quota.resetAt);
- {/* Limit (Progress + Numbers) */}
-
-
- {/* Progress bar - always show with border for visibility */}
-
-
- {/* Numbers */}
-
-
- {quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"}
-
-
- {remaining}%
+ return (
+
+ |
+
+ {colors.emoji}
+
+ {quota.name}
-
- |
+
- {/* Reset Time */}
-
- {countdown !== "-" || resetDisplay ? (
- compact ? (
-
- {countdown !== "-" ? `in ${countdown}` : resetDisplay}
+
+
+
+
+
+
+ {quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"}
+
+
+ {quota.remaining}%
+
+
+
+ |
+
+
+ {countdown !== "-" || resetDisplay ? (
+ compact ? (
+
+ {countdown !== "-" ? `in ${countdown}` : resetDisplay}
+
+ ) : (
+
+ {countdown !== "-" && (
+
+ in {countdown}
+
+ )}
+ {resetDisplay && (
+
+ {resetDisplay}
+
+ )}
+
+ )
) : (
-
- {countdown !== "-" && (
-
- in {countdown}
-
- )}
- {resetDisplay && (
-
- {resetDisplay}
-
- )}
-
- )
- ) : (
- N/A
- )}
- |
- |
- );
- })}
- |
-
+
N/A
+ )}
+
+
+ );
+ })}
+
+
+
+
+ {totalPages > 1 && (
+
+
+
+ Showing {pageStart}-{pageEnd} of {sortedQuotas.length}
+
+
+ Page {page} / {totalPages}
+
+
+
+
+
+
+
+ )}
);
}
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js
index ab252d7a..149f7d3c 100644
--- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js
+++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js
@@ -1,34 +1,233 @@
"use client";
-import { useState, useEffect, useCallback, useRef } from "react";
+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, USAGE_APIKEY_PROVIDERS } from "@/shared/constants/providers";
+import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
-// Connection is eligible for the quota page when it uses OAuth or is an apikey provider whitelisted for quota
-const isUsageEligible = (conn) =>
- USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
- (conn.authType === "oauth" || USAGE_APIKEY_PROVIDERS.includes(conn.provider));
+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; // 60 seconds
const DEPLETED_QUOTA_THRESHOLD = 5; // percent
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(() => {
- if (typeof window === "undefined") return true;
- const stored = window.localStorage.getItem(AUTO_REFRESH_STORAGE_KEY);
- return stored === null ? true : stored === "true";
- });
+ 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);
@@ -38,29 +237,72 @@ export default function ProviderLimits() {
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);
- // Fetch all provider connections
- const fetchConnections = useCallback(async () => {
- try {
- const response = await fetch("/api/providers/client");
- if (!response.ok) throw new Error("Failed to fetch connections");
+ const fetchConnections = useCallback(
+ async (targetPage = page) => {
+ try {
+ const params = new URLSearchParams({
+ page: String(targetPage),
+ pageSize: String(pageSize),
+ accountStatus: accountFilter,
+ sort: "priority",
+ });
- const data = await response.json();
- const connectionList = data.connections || [];
- setConnections(connectionList);
- return connectionList;
- } catch (error) {
- console.error("Error fetching connections:", error);
- setConnections([]);
- return [];
- }
- }, []);
+ 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],
+ );
// Fetch quota for a specific connection
const fetchQuota = useCallback(async (connectionId, provider) => {
@@ -92,13 +334,15 @@ export default function ProviderLimits() {
`[ProviderLimits] Auth error for ${provider}:`,
errorMsg,
);
+ const quotaEntry = {
+ quotas: [],
+ message: errorMsg,
+ };
setQuotaData((prev) => ({
...prev,
- [connectionId]: {
- quotas: [],
- message: errorMsg,
- },
+ [connectionId]: quotaEntry,
}));
+ setQuotaCache(connectionId, quotaEntry);
return;
}
@@ -111,15 +355,18 @@ export default function ProviderLimits() {
// Parse quota data using provider-specific parser
const parsedQuotas = parseQuotaData(provider, data);
+ const quotaEntry = {
+ quotas: parsedQuotas,
+ plan: data.plan || null,
+ message: data.message || null,
+ raw: data,
+ };
+
setQuotaData((prev) => ({
...prev,
- [connectionId]: {
- quotas: parsedQuotas,
- plan: data.plan || null,
- message: data.message || null,
- raw: data,
- },
+ [connectionId]: quotaEntry,
}));
+ setQuotaCache(connectionId, quotaEntry);
} catch (error) {
console.error(
`[ProviderLimits] Error fetching quota for ${provider} (${connectionId}):`,
@@ -143,55 +390,79 @@ export default function ProviderLimits() {
[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) {
- setConnections((prev) => prev.filter((c) => c.id !== id));
- 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;
- });
- }
- } catch (error) {
- console.error("Error deleting connection:", error);
- } finally {
- setDeletingId(null);
- }
- }, []);
+ 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;
+ });
- 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) {
- setConnections((prev) =>
- prev.map((c) => (c.id === id ? { ...c, isActive } : c)),
- );
+ 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);
}
- } catch (error) {
- console.error("Error updating connection status:", error);
- } finally {
- setTogglingId(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) => {
@@ -234,7 +505,6 @@ export default function ProviderLimits() {
};
}, []);
- // Refresh all providers
const refreshAll = useCallback(async () => {
if (refreshingAll) return;
@@ -242,13 +512,18 @@ export default function ProviderLimits() {
setCountdown(60);
try {
- const conns = await fetchConnections();
+ const visibleConnections = await fetchConnections(page);
- // Filter eligible connections (OAuth + whitelisted apikey)
- const eligibleConnections = conns.filter(isUsageEligible);
+ setLoading(buildLoadingState(visibleConnections));
+ setErrors((prev) =>
+ filterQuotaStateByConnections(prev, visibleConnections),
+ );
+ setQuotaData((prev) =>
+ filterQuotaStateByConnections(prev, visibleConnections),
+ );
await Promise.all(
- eligibleConnections.map((conn) => fetchQuota(conn.id, conn.provider)),
+ visibleConnections.map((conn) => fetchQuota(conn.id, conn.provider)),
);
setLastUpdated(new Date());
@@ -257,42 +532,91 @@ export default function ProviderLimits() {
} finally {
setRefreshingAll(false);
}
- }, [refreshingAll, fetchConnections, fetchQuota]);
+ }, [refreshingAll, fetchConnections, fetchQuota, page]);
- // Initial load: fetch connections first so cards render immediately, then fetch quotas
useEffect(() => {
const initializeData = async () => {
setConnectionsLoading(true);
- const conns = await fetchConnections();
+ const visibleConnections = await fetchConnections(page);
setConnectionsLoading(false);
- const eligibleConnections = conns.filter(isUsageEligible);
+ const cache = getQuotaCache();
+ const nextLoading = {};
+ const cachedQuotas = {};
+ const connectionsToFetch = [];
+ let latestCachedAt = null;
- // Mark all as loading before fetching
- const loadingState = {};
- eligibleConnections.forEach((conn) => {
- loadingState[conn.id] = true;
+ visibleConnections.forEach((conn) => {
+ const cachedEntry = cache[conn.id];
+ if (cachedEntry) {
+ nextLoading[conn.id] = false;
+ cachedQuotas[conn.id] = {
+ quotas: cachedEntry.quotas,
+ plan: cachedEntry.plan,
+ message: cachedEntry.message,
+ raw: cachedEntry.raw,
+ };
+ if (cachedEntry.cachedAt) {
+ const cachedTime = new Date(cachedEntry.cachedAt);
+ if (!latestCachedAt || cachedTime > latestCachedAt) {
+ latestCachedAt = cachedTime;
+ }
+ }
+ } else {
+ nextLoading[conn.id] = true;
+ connectionsToFetch.push(conn);
+ }
});
- setLoading(loadingState);
- await Promise.all(
- eligibleConnections.map((conn) => fetchQuota(conn.id, conn.provider)),
- );
- setLastUpdated(new Date());
+ setLoading(nextLoading);
+ setErrors((prev) => {
+ const nextErrors = filterQuotaStateByConnections(
+ prev,
+ visibleConnections,
+ );
+ visibleConnections.forEach((conn) => {
+ if (cache[conn.id]) {
+ nextErrors[conn.id] = null;
+ }
+ });
+ return nextErrors;
+ });
+ setQuotaData((prev) => ({
+ ...filterQuotaStateByConnections(prev, visibleConnections),
+ ...cachedQuotas,
+ }));
+
+ if (latestCachedAt) {
+ setLastUpdated(latestCachedAt);
+ }
+
+ if (connectionsToFetch.length > 0) {
+ await Promise.all(
+ connectionsToFetch.map((conn) => fetchQuota(conn.id, conn.provider)),
+ );
+ setLastUpdated(new Date());
+ }
};
initializeData();
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [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);
+ }, []);
// Persist auto-refresh preference
useEffect(() => {
- if (typeof window === "undefined") return;
+ if (typeof window === "undefined" || !hasHydratedAutoRefresh) return;
window.localStorage.setItem(AUTO_REFRESH_STORAGE_KEY, String(autoRefresh));
- }, [autoRefresh]);
+ }, [autoRefresh, hasHydratedAutoRefresh]);
// Auto-refresh interval
useEffect(() => {
- if (!autoRefresh) {
+ if (!hasHydratedAutoRefresh || !autoRefresh) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
@@ -321,7 +645,7 @@ export default function ProviderLimits() {
if (intervalRef.current) clearInterval(intervalRef.current);
if (countdownRef.current) clearInterval(countdownRef.current);
};
- }, [autoRefresh, refreshAll]);
+ }, [autoRefresh, refreshAll, hasHydratedAutoRefresh]);
// Pause auto-refresh when tab is hidden (Page Visibility API)
useEffect(() => {
@@ -335,7 +659,7 @@ export default function ProviderLimits() {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
- } else if (autoRefresh) {
+ } else if (autoRefresh && hasHydratedAutoRefresh) {
// Resume auto-refresh when tab becomes visible
intervalRef.current = setInterval(refreshAll, REFRESH_INTERVAL_MS);
countdownRef.current = setInterval(() => {
@@ -348,35 +672,20 @@ export default function ProviderLimits() {
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
- }, [autoRefresh, refreshAll]);
+ }, [autoRefresh, refreshAll, hasHydratedAutoRefresh]);
- // Filter eligible connections (OAuth + whitelisted apikey)
- const filteredConnections = connections.filter(isUsageEligible);
-
- const providerFilteredConnections = filteredConnections.filter(
- (conn) => providerFilter === "all" || conn.provider === providerFilter,
+ const sortedConnections = useMemo(
+ () =>
+ sortVisibleConnections(
+ connections,
+ quotaData,
+ expiringFirst,
+ providerFilter,
+ quotaSortMode,
+ ),
+ [connections, quotaData, expiringFirst, providerFilter, quotaSortMode],
);
- const getEarliestResetTime = (conn) => {
- const resetTimes = (quotaData[conn.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;
- };
-
- // Sort providers by USAGE_SUPPORTED_PROVIDERS order, then alphabetically.
- // Optionally surface accounts with quotas expiring soonest first.
- const sortedConnections = [...providerFilteredConnections].sort((a, b) => {
- if (expiringFirst) {
- const expiryDiff = getEarliestResetTime(a) - getEarliestResetTime(b);
- if (expiryDiff !== 0) return expiryDiff;
- }
- const orderA = USAGE_SUPPORTED_PROVIDERS.indexOf(a.provider);
- const orderB = USAGE_SUPPORTED_PROVIDERS.indexOf(b.provider);
- if (orderA !== orderB) return orderA - orderB;
- return a.provider.localeCompare(b.provider);
- });
-
// Connection is depleted when any quota entry hit the threshold
const isConnectionDepleted = (conn) => {
const quotas = quotaData[conn.id]?.quotas;
@@ -401,16 +710,14 @@ export default function ProviderLimits() {
}),
),
);
- setConnections((prev) =>
- prev.map((c) => (targetIds.includes(c.id) ? { ...c, isActive } : c)),
- );
+ await reconcileConnectionsPage(fetchConnections, page);
} catch (error) {
console.error("Error bulk toggling connections:", error);
} finally {
setBulkToggling(false);
}
},
- [bulkToggling],
+ [bulkToggling, fetchConnections, page],
);
const handleDisableDepleted = () => {
@@ -427,29 +734,20 @@ export default function ProviderLimits() {
bulkSetActive(ids, true);
};
- const providerOptions = Array.from(new Set(filteredConnections.map((conn) => conn.provider))).sort();
- const selectedProviderLabel = providerFilter === "all" ? "All providers" : providerFilter;
+ 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);
- // Calculate summary stats
- const totalProviders = sortedConnections.length;
- const activeWithLimits = Object.values(quotaData).filter(
- (data) => data?.quotas?.length > 0,
- ).length;
-
- // Count low quotas (remaining < 30%)
- const lowQuotasCount = Object.values(quotaData).reduce((count, data) => {
- if (!data?.quotas) return count;
-
- const hasLowQuota = data.quotas.some((quota) => {
- const percentage = calculatePercentage(quota.used, quota.total);
- return percentage < 30 && quota.total > 0;
- });
-
- return count + (hasLowQuota ? 1 : 0);
- }, 0);
-
- // Empty state
- if (!connectionsLoading && sortedConnections.length === 0) {
+ if (!connectionsLoading && !hasEligibleConnections) {
return (
@@ -468,16 +766,28 @@ export default function ProviderLimits() {
);
}
+ if (!connectionsLoading && !hasVisibleConnections) {
+ return (
+
+
+
+ {emptyState.icon}
+
+
+ {emptyState.title}
+
+
+ {emptyState.description}
+
+
+
+ );
+ }
+
return (
{/* Header Controls */}
-
-
-
- Provider Limits
-
-
-
+
{providerMenuOpen && (
@@ -516,12 +832,24 @@ export default function ProviderLimits() {
@@ -529,7 +857,13 @@ export default function ProviderLimits() {
))}
@@ -548,13 +888,50 @@ export default function ProviderLimits() {
>
)}
+
+
+ {providerFilter === "codex" && (
+
+ )}
+
@@ -564,7 +941,7 @@ export default function ProviderLimits() {
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 (within current filter)"
+ title="Disable connections with depleted quota on the current page"
>
block
Turn off Empty
@@ -576,9 +953,11 @@ export default function ProviderLimits() {
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 (within current filter)"
+ title="Enable connections that still have quota on the current page"
>
-
check_circle
+
+ check_circle
+
Turn on Available
@@ -595,9 +974,13 @@ export default function ProviderLimits() {
>
{autoRefresh ? "toggle_on" : "toggle_off"}
-
Auto-refresh
+
+ Auto-refresh
+
{autoRefresh && (
-
({countdown}s)
+
+ ({countdown}s)
+
)}
@@ -609,12 +992,23 @@ export default function ProviderLimits() {
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"
>
-
refresh
+
+ refresh
+
{/* Provider cards: 2 columns, compact */}
+ {expiringFirst && (
+
+ Expiring-first currently reorders accounts inside the current page.
+ Cross-page ordering still follows backend pagination.
+
+ )}
+
{sortedConnections.map((conn) => {
const quota = quotaData[conn.id];
@@ -649,13 +1043,11 @@ export default function ProviderLimits() {
{conn.provider}
- {(() => {
- const isEmail = (v) => typeof v === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
- const label = isEmail(conn.email) ? conn.email : (isEmail(conn.name) ? conn.name : conn.name);
- return label ? (
-
{label}
- ) : null;
- })()}
+ {getConnectionLabel(conn) ? (
+
+ {getConnectionLabel(conn)}
+
+ ) : null}
@@ -664,6 +1056,7 @@ export default function ProviderLimits() {
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"
>
@@ -680,6 +1073,7 @@ export default function ProviderLimits() {
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"
>
@@ -691,6 +1085,7 @@ export default function ProviderLimits() {
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"
>
@@ -740,7 +1135,14 @@ export default function ProviderLimits() {
{quota.message}
) : (
-
+
)}
@@ -748,6 +1150,129 @@ export default function ProviderLimits() {
})}
+
+
+
{connectionsPageSummary}
+
+
+ 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"
+ />
+ Page {pagination.page} / {pagination.totalPages}
+
+
+
+
+
+
+
+
+
+
0 ? parsed : fallback;
+}
+
+function sortConnections(connections, sort) {
+ const list = [...connections];
+
+ if (sort === "provider") {
+ return list.sort((a, b) => {
+ const orderA = USAGE_SUPPORTED_PROVIDERS.indexOf(a.provider);
+ const orderB = USAGE_SUPPORTED_PROVIDERS.indexOf(b.provider);
+ if (orderA !== orderB) return orderA - orderB;
+ return a.provider.localeCompare(b.provider);
+ });
+ }
+
+ return list.sort((a, b) => {
+ const priorityA = a.priority ?? Number.MAX_SAFE_INTEGER;
+ const priorityB = b.priority ?? Number.MAX_SAFE_INTEGER;
+ if (priorityA !== priorityB) return priorityA - priorityB;
+ return (a.provider || "").localeCompare(b.provider || "");
+ });
+}
+
+export async function GET(request) {
try {
await backfillCodexEmails();
- const connections = await getProviderConnections();
- return NextResponse.json({ connections: connections.map(sanitize) });
+
+ const { searchParams } = new URL(request.url);
+ const provider = searchParams.get("provider") || "all";
+ const accountStatus = searchParams.get("accountStatus") || "all";
+ const sort = searchParams.get("sort") || "priority";
+ const page = parsePositiveInt(searchParams.get("page"), 1);
+ const pageSize = Math.min(parsePositiveInt(searchParams.get("pageSize"), DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE);
+
+ const allConnections = await getProviderConnections();
+ const eligibleConnections = allConnections.filter(isUsageEligible);
+ const providerOptions = Array.from(new Set(eligibleConnections.map((conn) => conn.provider))).sort();
+
+ const providerFilteredConnections = eligibleConnections.filter((conn) => (
+ provider === "all" || conn.provider === provider
+ ));
+
+ const accountFilteredConnections = providerFilteredConnections.filter((conn) => {
+ if (accountStatus === "active") return conn.isActive ?? true;
+ if (accountStatus === "inactive") return !(conn.isActive ?? true);
+ return true;
+ });
+
+ const sortedConnections = sortConnections(accountFilteredConnections, sort);
+ const total = sortedConnections.length;
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
+ const currentPage = Math.min(page, totalPages);
+ const offset = (currentPage - 1) * pageSize;
+ const pageConnections = sortedConnections.slice(offset, offset + pageSize).map(sanitize);
+
+ return NextResponse.json({
+ connections: pageConnections,
+ providerOptions,
+ pagination: {
+ page: currentPage,
+ pageSize,
+ total,
+ totalPages,
+ },
+ totals: {
+ eligibleConnections: eligibleConnections.length,
+ providerFilteredConnections: providerFilteredConnections.length,
+ },
+ });
} catch (error) {
console.log("Error fetching providers for client:", error);
return NextResponse.json({ error: "Failed to fetch providers" }, { status: 500 });
diff --git a/src/lib/tunnel/tailscale.js b/src/lib/tunnel/tailscale.js
index 78734c55..88245a6a 100644
--- a/src/lib/tunnel/tailscale.js
+++ b/src/lib/tunnel/tailscale.js
@@ -49,7 +49,8 @@ function fallbackBin() {
function bgRefreshBin() {
if (binCache.refreshing) return;
binCache.refreshing = true;
- execAsync("which tailscale 2>/dev/null || where tailscale 2>nul", { windowsHide: true, timeout: PROBE_TIMEOUT_MS })
+ const cmd = IS_WINDOWS ? "where tailscale 2>nul" : "which tailscale 2>/dev/null";
+ execAsync(cmd, { windowsHide: true, timeout: PROBE_TIMEOUT_MS })
.then(({ stdout }) => {
const sys = stdout.trim();
binCache.value = sys || fallbackBin();
@@ -138,9 +139,10 @@ export function isTailscaleRunningStrict() {
const bin = getTailscaleBin();
if (!bin) return false;
try {
- const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel status --json 2>/dev/null`, {
+ const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel status --json`, {
encoding: "utf8",
windowsHide: true,
+ stdio: ["ignore", "pipe", "ignore"],
timeout: PROBE_TIMEOUT_MS,
});
const json = JSON.parse(out);