2026-02-03 21:54:11 -05:00
|
|
|
"use client";
|
|
|
|
|
|
2026-05-21 01:15:07 -04:00
|
|
|
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
2026-03-22 22:16:10 -04:00
|
|
|
import ProviderIcon from "@/shared/components/ProviderIcon";
|
2026-02-05 06:38:50 -05:00
|
|
|
import QuotaTable from "./QuotaTable";
|
2026-04-04 00:25:58 -04:00
|
|
|
import Toggle from "@/shared/components/Toggle";
|
2026-02-03 21:54:11 -05:00
|
|
|
import { parseQuotaData, calculatePercentage } from "./utils";
|
|
|
|
|
import Card from "@/shared/components/Card";
|
2026-04-04 00:25:58 -04:00
|
|
|
import { EditConnectionModal } from "@/shared/components";
|
2026-05-21 01:15:07 -04:00
|
|
|
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;
|
|
|
|
|
}
|
feat: add STT support, Gemini TTS, and expand usage tracking
- Speech-to-Text: full pipeline with sttCore handler, /v1/audio/transcriptions
endpoint, sttConfig for OpenAI, Gemini, Groq, Deepgram, AssemblyAI,
HuggingFace, NVIDIA Parakeet; new 9router-stt skill
- Gemini TTS: add gemini provider with 30 prebuilt voices and TTS_PROVIDER_CONFIG
- Usage: implement GLM (intl/cn) and MiniMax (intl/cn) quota fetchers; refactor
Gemini CLI usage to use retrieveUserQuota with per-model buckets
- Disabled models: lowdb-backed disabledModelsDb + /api/models/disabled route
- Header search: reusable Zustand store (headerSearchStore) wired into Header
- CLI tools: add Claude Cowork tool card and cowork-settings API
- Providers: introduce mediaPriority sorting in getProvidersByKind, add
Kimi K2.6, reorder hermes, drop qwen STT kind
- UI: expand media-providers/[kind]/[id] page (+314), enhance OAuthModal,
ModelSelectModal, ProviderTopology, ProxyPools, ProviderLimits
- Assets: refresh provider PNGs (alicode, byteplus, cloudflare-ai, nvidia,
ollama, vertex, volcengine-ark) and add aws-polly, fal-ai, jina-ai, recraft,
runwayml, stability-ai, topaz, black-forest-labs
2026-05-04 23:32:59 -04:00
|
|
|
|
2026-05-21 01:15:07 -04:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-03 21:54:11 -05:00
|
|
|
|
|
|
|
|
const REFRESH_INTERVAL_MS = 60000; // 60 seconds
|
2026-05-03 11:10:03 -04:00
|
|
|
const DEPLETED_QUOTA_THRESHOLD = 5; // percent
|
|
|
|
|
const AUTO_REFRESH_STORAGE_KEY = "quotaAutoRefresh";
|
2026-05-21 01:15:07 -04:00
|
|
|
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;
|
2026-02-03 21:54:11 -05:00
|
|
|
|
|
|
|
|
export default function ProviderLimits() {
|
|
|
|
|
const [connections, setConnections] = useState([]);
|
|
|
|
|
const [quotaData, setQuotaData] = useState({});
|
|
|
|
|
const [loading, setLoading] = useState({});
|
|
|
|
|
const [errors, setErrors] = useState({});
|
2026-05-21 01:15:07 -04:00
|
|
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
2026-02-03 21:54:11 -05:00
|
|
|
const [lastUpdated, setLastUpdated] = useState(null);
|
2026-05-21 01:15:07 -04:00
|
|
|
const [hasHydratedAutoRefresh, setHasHydratedAutoRefresh] = useState(false);
|
2026-02-03 21:54:11 -05:00
|
|
|
const [refreshingAll, setRefreshingAll] = useState(false);
|
|
|
|
|
const [countdown, setCountdown] = useState(60);
|
2026-03-02 21:53:30 -05:00
|
|
|
const [connectionsLoading, setConnectionsLoading] = useState(true);
|
2026-04-04 00:25:58 -04:00
|
|
|
const [deletingId, setDeletingId] = useState(null);
|
|
|
|
|
const [togglingId, setTogglingId] = useState(null);
|
|
|
|
|
const [showEditModal, setShowEditModal] = useState(false);
|
|
|
|
|
const [selectedConnection, setSelectedConnection] = useState(null);
|
|
|
|
|
const [proxyPools, setProxyPools] = useState([]);
|
2026-04-27 23:05:28 -04:00
|
|
|
const [providerFilter, setProviderFilter] = useState("all");
|
2026-05-21 01:15:07 -04:00
|
|
|
const [providerOptions, setProviderOptions] = useState([]);
|
|
|
|
|
const [accountFilter, setAccountFilter] = useState("all");
|
|
|
|
|
const [quotaSortMode, setQuotaSortMode] = useState("default");
|
2026-04-27 23:05:28 -04:00
|
|
|
const [expiringFirst, setExpiringFirst] = useState(false);
|
2026-05-01 05:34:07 -04:00
|
|
|
const [providerMenuOpen, setProviderMenuOpen] = useState(false);
|
2026-05-03 11:10:03 -04:00
|
|
|
const [bulkToggling, setBulkToggling] = useState(false);
|
2026-05-21 01:15:07 -04:00
|
|
|
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,
|
|
|
|
|
});
|
2026-02-03 21:54:11 -05:00
|
|
|
|
|
|
|
|
const intervalRef = useRef(null);
|
|
|
|
|
const countdownRef = useRef(null);
|
|
|
|
|
|
2026-05-21 01:15:07 -04:00
|
|
|
const fetchConnections = useCallback(
|
|
|
|
|
async (targetPage = page) => {
|
|
|
|
|
try {
|
|
|
|
|
const params = new URLSearchParams({
|
|
|
|
|
page: String(targetPage),
|
|
|
|
|
pageSize: String(pageSize),
|
|
|
|
|
accountStatus: accountFilter,
|
|
|
|
|
sort: "priority",
|
|
|
|
|
});
|
2026-03-22 22:16:10 -04:00
|
|
|
|
2026-05-21 01:15:07 -04:00
|
|
|
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],
|
|
|
|
|
);
|
2026-02-03 21:54:11 -05:00
|
|
|
|
|
|
|
|
// Fetch quota for a specific connection
|
|
|
|
|
const fetchQuota = useCallback(async (connectionId, provider) => {
|
|
|
|
|
setLoading((prev) => ({ ...prev, [connectionId]: true }));
|
|
|
|
|
setErrors((prev) => ({ ...prev, [connectionId]: null }));
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-22 22:16:10 -04:00
|
|
|
console.log(
|
|
|
|
|
`[ProviderLimits] Fetching quota for ${provider} (${connectionId})`,
|
|
|
|
|
);
|
2026-02-03 21:54:11 -05:00
|
|
|
const response = await fetch(`/api/usage/${connectionId}`);
|
2026-03-22 22:16:10 -04:00
|
|
|
|
2026-02-03 21:54:11 -05:00
|
|
|
if (!response.ok) {
|
2026-02-05 06:38:50 -05:00
|
|
|
const errorData = await response.json().catch(() => ({}));
|
|
|
|
|
const errorMsg = errorData.error || response.statusText;
|
2026-03-22 22:16:10 -04:00
|
|
|
|
2026-02-05 06:38:50 -05:00
|
|
|
// Handle different error types gracefully
|
|
|
|
|
if (response.status === 404) {
|
|
|
|
|
// Connection not found - skip silently
|
2026-03-22 22:16:10 -04:00
|
|
|
console.warn(
|
|
|
|
|
`[ProviderLimits] Connection not found for ${provider}, skipping`,
|
|
|
|
|
);
|
2026-02-05 06:38:50 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-03-22 22:16:10 -04:00
|
|
|
|
2026-02-05 06:38:50 -05:00
|
|
|
if (response.status === 401) {
|
|
|
|
|
// Auth error - show message instead of throwing
|
2026-03-22 22:16:10 -04:00
|
|
|
console.warn(
|
|
|
|
|
`[ProviderLimits] Auth error for ${provider}:`,
|
|
|
|
|
errorMsg,
|
|
|
|
|
);
|
2026-05-21 01:15:07 -04:00
|
|
|
const quotaEntry = {
|
|
|
|
|
quotas: [],
|
|
|
|
|
message: errorMsg,
|
|
|
|
|
};
|
2026-02-05 06:38:50 -05:00
|
|
|
setQuotaData((prev) => ({
|
|
|
|
|
...prev,
|
2026-05-21 01:15:07 -04:00
|
|
|
[connectionId]: quotaEntry,
|
2026-02-05 06:38:50 -05:00
|
|
|
}));
|
2026-05-21 01:15:07 -04:00
|
|
|
setQuotaCache(connectionId, quotaEntry);
|
2026-02-05 06:38:50 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-03-22 22:16:10 -04:00
|
|
|
|
2026-02-05 06:38:50 -05:00
|
|
|
throw new Error(`HTTP ${response.status}: ${errorMsg}`);
|
2026-02-03 21:54:11 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
console.log(`[ProviderLimits] Got quota for ${provider}:`, data);
|
2026-03-22 22:16:10 -04:00
|
|
|
|
2026-02-03 21:54:11 -05:00
|
|
|
// Parse quota data using provider-specific parser
|
|
|
|
|
const parsedQuotas = parseQuotaData(provider, data);
|
2026-03-22 22:16:10 -04:00
|
|
|
|
2026-05-21 01:15:07 -04:00
|
|
|
const quotaEntry = {
|
|
|
|
|
quotas: parsedQuotas,
|
|
|
|
|
plan: data.plan || null,
|
|
|
|
|
message: data.message || null,
|
|
|
|
|
raw: data,
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-03 21:54:11 -05:00
|
|
|
setQuotaData((prev) => ({
|
|
|
|
|
...prev,
|
2026-05-21 01:15:07 -04:00
|
|
|
[connectionId]: quotaEntry,
|
2026-02-03 21:54:11 -05:00
|
|
|
}));
|
2026-05-21 01:15:07 -04:00
|
|
|
setQuotaCache(connectionId, quotaEntry);
|
2026-02-03 21:54:11 -05:00
|
|
|
} catch (error) {
|
2026-03-22 22:16:10 -04:00
|
|
|
console.error(
|
|
|
|
|
`[ProviderLimits] Error fetching quota for ${provider} (${connectionId}):`,
|
|
|
|
|
error,
|
|
|
|
|
);
|
2026-02-03 21:54:11 -05:00
|
|
|
setErrors((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[connectionId]: error.message || "Failed to fetch quota",
|
|
|
|
|
}));
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading((prev) => ({ ...prev, [connectionId]: false }));
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Refresh quota for a specific provider
|
|
|
|
|
const refreshProvider = useCallback(
|
|
|
|
|
async (connectionId, provider) => {
|
|
|
|
|
await fetchQuota(connectionId, provider);
|
|
|
|
|
setLastUpdated(new Date());
|
|
|
|
|
},
|
2026-03-22 22:16:10 -04:00
|
|
|
[fetchQuota],
|
2026-02-03 21:54:11 -05:00
|
|
|
);
|
|
|
|
|
|
2026-05-21 01:15:07 -04:00
|
|
|
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);
|
2026-04-04 00:25:58 -04:00
|
|
|
}
|
2026-05-21 01:15:07 -04:00
|
|
|
},
|
|
|
|
|
[fetchConnections, page],
|
|
|
|
|
);
|
2026-04-04 00:25:58 -04:00
|
|
|
|
2026-05-21 01:15:07 -04:00
|
|
|
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);
|
2026-04-04 00:25:58 -04:00
|
|
|
}
|
2026-05-21 01:15:07 -04:00
|
|
|
},
|
|
|
|
|
[fetchConnections, page],
|
|
|
|
|
);
|
2026-04-04 00:25:58 -04:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-03 21:54:11 -05:00
|
|
|
const refreshAll = useCallback(async () => {
|
|
|
|
|
if (refreshingAll) return;
|
|
|
|
|
|
|
|
|
|
setRefreshingAll(true);
|
|
|
|
|
setCountdown(60);
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-21 01:15:07 -04:00
|
|
|
const visibleConnections = await fetchConnections(page);
|
2026-03-22 22:16:10 -04:00
|
|
|
|
2026-05-21 01:15:07 -04:00
|
|
|
setLoading(buildLoadingState(visibleConnections));
|
|
|
|
|
setErrors((prev) =>
|
|
|
|
|
filterQuotaStateByConnections(prev, visibleConnections),
|
|
|
|
|
);
|
|
|
|
|
setQuotaData((prev) =>
|
|
|
|
|
filterQuotaStateByConnections(prev, visibleConnections),
|
|
|
|
|
);
|
2026-03-22 22:16:10 -04:00
|
|
|
|
2026-02-03 21:54:11 -05:00
|
|
|
await Promise.all(
|
2026-05-21 01:15:07 -04:00
|
|
|
visibleConnections.map((conn) => fetchQuota(conn.id, conn.provider)),
|
2026-02-03 21:54:11 -05:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
setLastUpdated(new Date());
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Error refreshing all providers:", error);
|
|
|
|
|
} finally {
|
|
|
|
|
setRefreshingAll(false);
|
|
|
|
|
}
|
2026-05-21 01:15:07 -04:00
|
|
|
}, [refreshingAll, fetchConnections, fetchQuota, page]);
|
2026-02-03 21:54:11 -05:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const initializeData = async () => {
|
2026-03-02 21:53:30 -05:00
|
|
|
setConnectionsLoading(true);
|
2026-05-21 01:15:07 -04:00
|
|
|
const visibleConnections = await fetchConnections(page);
|
2026-03-02 21:53:30 -05:00
|
|
|
setConnectionsLoading(false);
|
|
|
|
|
|
2026-05-21 01:15:07 -04:00
|
|
|
const cache = getQuotaCache();
|
|
|
|
|
const nextLoading = {};
|
|
|
|
|
const cachedQuotas = {};
|
|
|
|
|
const connectionsToFetch = [];
|
|
|
|
|
let latestCachedAt = null;
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-03-02 21:53:30 -05:00
|
|
|
|
2026-05-21 01:15:07 -04:00
|
|
|
setLoading(nextLoading);
|
|
|
|
|
setErrors((prev) => {
|
|
|
|
|
const nextErrors = filterQuotaStateByConnections(
|
|
|
|
|
prev,
|
|
|
|
|
visibleConnections,
|
|
|
|
|
);
|
|
|
|
|
visibleConnections.forEach((conn) => {
|
|
|
|
|
if (cache[conn.id]) {
|
|
|
|
|
nextErrors[conn.id] = null;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return nextErrors;
|
2026-03-22 22:16:10 -04:00
|
|
|
});
|
2026-05-21 01:15:07 -04:00
|
|
|
setQuotaData((prev) => ({
|
|
|
|
|
...filterQuotaStateByConnections(prev, visibleConnections),
|
|
|
|
|
...cachedQuotas,
|
|
|
|
|
}));
|
2026-03-02 21:53:30 -05:00
|
|
|
|
2026-05-21 01:15:07 -04:00
|
|
|
if (latestCachedAt) {
|
|
|
|
|
setLastUpdated(latestCachedAt);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (connectionsToFetch.length > 0) {
|
|
|
|
|
await Promise.all(
|
|
|
|
|
connectionsToFetch.map((conn) => fetchQuota(conn.id, conn.provider)),
|
|
|
|
|
);
|
|
|
|
|
setLastUpdated(new Date());
|
|
|
|
|
}
|
2026-02-03 21:54:11 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
initializeData();
|
2026-05-21 01:15:07 -04:00
|
|
|
}, [fetchConnections, fetchQuota, page]);
|
2026-02-03 21:54:11 -05:00
|
|
|
|
2026-05-03 11:10:03 -04:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (typeof window === "undefined") return;
|
2026-05-21 01:15:07 -04:00
|
|
|
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" || !hasHydratedAutoRefresh) return;
|
2026-05-03 11:10:03 -04:00
|
|
|
window.localStorage.setItem(AUTO_REFRESH_STORAGE_KEY, String(autoRefresh));
|
2026-05-21 01:15:07 -04:00
|
|
|
}, [autoRefresh, hasHydratedAutoRefresh]);
|
2026-05-03 11:10:03 -04:00
|
|
|
|
2026-02-03 21:54:11 -05:00
|
|
|
// Auto-refresh interval
|
|
|
|
|
useEffect(() => {
|
2026-05-21 01:15:07 -04:00
|
|
|
if (!hasHydratedAutoRefresh || !autoRefresh) {
|
2026-02-03 21:54:11 -05:00
|
|
|
if (intervalRef.current) {
|
|
|
|
|
clearInterval(intervalRef.current);
|
|
|
|
|
intervalRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
if (countdownRef.current) {
|
|
|
|
|
clearInterval(countdownRef.current);
|
|
|
|
|
countdownRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Main refresh interval
|
|
|
|
|
intervalRef.current = setInterval(() => {
|
|
|
|
|
refreshAll();
|
|
|
|
|
}, REFRESH_INTERVAL_MS);
|
|
|
|
|
|
|
|
|
|
// Countdown interval
|
|
|
|
|
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);
|
|
|
|
|
};
|
2026-05-21 01:15:07 -04:00
|
|
|
}, [autoRefresh, refreshAll, hasHydratedAutoRefresh]);
|
2026-02-03 21:54:11 -05:00
|
|
|
|
|
|
|
|
// Pause auto-refresh when tab is hidden (Page Visibility API)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleVisibilityChange = () => {
|
|
|
|
|
if (document.hidden) {
|
|
|
|
|
if (intervalRef.current) {
|
|
|
|
|
clearInterval(intervalRef.current);
|
|
|
|
|
intervalRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
if (countdownRef.current) {
|
|
|
|
|
clearInterval(countdownRef.current);
|
|
|
|
|
countdownRef.current = null;
|
|
|
|
|
}
|
2026-05-21 01:15:07 -04:00
|
|
|
} else if (autoRefresh && hasHydratedAutoRefresh) {
|
2026-02-03 21:54:11 -05:00
|
|
|
// Resume auto-refresh when tab becomes visible
|
|
|
|
|
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);
|
|
|
|
|
};
|
2026-05-21 01:15:07 -04:00
|
|
|
}, [autoRefresh, refreshAll, hasHydratedAutoRefresh]);
|
|
|
|
|
|
|
|
|
|
const sortedConnections = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
sortVisibleConnections(
|
|
|
|
|
connections,
|
|
|
|
|
quotaData,
|
|
|
|
|
expiringFirst,
|
|
|
|
|
providerFilter,
|
|
|
|
|
quotaSortMode,
|
|
|
|
|
),
|
|
|
|
|
[connections, quotaData, expiringFirst, providerFilter, quotaSortMode],
|
2026-04-27 23:05:28 -04:00
|
|
|
);
|
|
|
|
|
|
2026-05-03 11:10:03 -04:00
|
|
|
// Connection is depleted when any quota entry hit the threshold
|
|
|
|
|
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 }),
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-05-21 01:15:07 -04:00
|
|
|
await reconcileConnectionsPage(fetchConnections, page);
|
2026-05-03 11:10:03 -04:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Error bulk toggling connections:", error);
|
|
|
|
|
} finally {
|
|
|
|
|
setBulkToggling(false);
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-05-21 01:15:07 -04:00
|
|
|
[bulkToggling, fetchConnections, page],
|
2026-05-03 11:10:03 -04:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-21 01:15:07 -04:00
|
|
|
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);
|
2026-02-03 21:54:11 -05:00
|
|
|
|
2026-05-21 01:15:07 -04:00
|
|
|
if (!connectionsLoading && !hasEligibleConnections) {
|
2026-02-03 21:54:11 -05:00
|
|
|
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">
|
2026-03-22 22:16:10 -04:00
|
|
|
Connect to providers with OAuth to track your API quota limits and
|
|
|
|
|
usage.
|
2026-02-03 21:54:11 -05:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 01:15:07 -04:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 21:54:11 -05:00
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{/* Header Controls */}
|
2026-05-21 01:15:07 -04:00
|
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-end">
|
2026-05-03 11:10:03 -04:00
|
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
2026-05-01 05:34:07 -04:00
|
|
|
<div className="relative">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setProviderMenuOpen((prev) => !prev)}
|
2026-05-03 11:10:03 -04:00
|
|
|
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"
|
2026-05-01 05:34:07 -04:00
|
|
|
aria-haspopup="menu"
|
|
|
|
|
aria-expanded={providerMenuOpen}
|
|
|
|
|
title="Filter quota providers"
|
|
|
|
|
>
|
2026-05-03 11:10:03 -04:00
|
|
|
<span className="flex min-w-0 items-center gap-1.5">
|
2026-05-01 05:34:07 -04:00
|
|
|
{providerFilter === "all" ? (
|
2026-05-21 01:15:07 -04:00
|
|
|
<span className="material-symbols-outlined text-[14px] text-text-muted">
|
|
|
|
|
apps
|
|
|
|
|
</span>
|
2026-05-01 05:34:07 -04:00
|
|
|
) : (
|
|
|
|
|
<ProviderIcon
|
|
|
|
|
src={`/providers/${providerFilter}.png`}
|
|
|
|
|
alt={providerFilter}
|
2026-05-03 11:10:03 -04:00
|
|
|
size={18}
|
|
|
|
|
className="size-[18px] rounded object-contain"
|
2026-05-01 05:34:07 -04:00
|
|
|
fallbackText={providerFilter.slice(0, 2).toUpperCase()}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-05-21 01:15:07 -04:00
|
|
|
<span className="truncate capitalize hidden lg:inline">
|
|
|
|
|
{selectedProviderLabel}
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
|
|
|
|
<span className="material-symbols-outlined text-[14px] text-text-muted">
|
|
|
|
|
expand_more
|
2026-05-01 05:34:07 -04:00
|
|
|
</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"
|
2026-05-21 01:15:07 -04:00
|
|
|
onClick={() => {
|
|
|
|
|
if (shouldResetPage(providerFilter, "all")) {
|
|
|
|
|
setPage(1);
|
|
|
|
|
}
|
|
|
|
|
setProviderFilter("all");
|
|
|
|
|
setProviderMenuOpen(false);
|
|
|
|
|
}}
|
2026-05-01 05:34:07 -04:00
|
|
|
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"}`}
|
|
|
|
|
>
|
2026-05-21 01:15:07 -04:00
|
|
|
<span className="material-symbols-outlined text-[22px]">
|
|
|
|
|
apps
|
|
|
|
|
</span>
|
2026-05-01 05:34:07 -04:00
|
|
|
<span className="font-medium">All providers</span>
|
2026-05-21 01:15:07 -04:00
|
|
|
{providerFilter === "all" && (
|
|
|
|
|
<span className="material-symbols-outlined ml-auto text-[20px]">
|
|
|
|
|
check
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-05-01 05:34:07 -04:00
|
|
|
</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"
|
2026-05-21 01:15:07 -04:00
|
|
|
onClick={() => {
|
|
|
|
|
if (shouldResetPage(providerFilter, provider)) {
|
|
|
|
|
setPage(1);
|
|
|
|
|
}
|
|
|
|
|
setProviderFilter(provider);
|
|
|
|
|
setProviderMenuOpen(false);
|
|
|
|
|
}}
|
2026-05-01 05:34:07 -04:00
|
|
|
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()}
|
|
|
|
|
/>
|
2026-05-21 01:15:07 -04:00
|
|
|
<span className="font-medium capitalize">
|
|
|
|
|
{provider}
|
|
|
|
|
</span>
|
|
|
|
|
{providerFilter === provider && (
|
|
|
|
|
<span className="material-symbols-outlined ml-auto text-[20px]">
|
|
|
|
|
check
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-05-01 05:34:07 -04:00
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-05-21 01:15:07 -04:00
|
|
|
<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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-04-27 23:05:28 -04:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setExpiringFirst((prev) => !prev)}
|
2026-05-21 01:15:07 -04:00
|
|
|
aria-pressed={expiringFirst}
|
2026-05-03 11:10:03 -04:00
|
|
|
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"}`}
|
2026-04-27 23:05:28 -04:00
|
|
|
title="Sort accounts by earliest quota reset time"
|
|
|
|
|
>
|
2026-05-21 01:15:07 -04:00
|
|
|
<span className="material-symbols-outlined text-[14px]">
|
|
|
|
|
hourglass_top
|
|
|
|
|
</span>
|
2026-05-01 05:34:07 -04:00
|
|
|
<span className="hidden sm:inline">Expiring first</span>
|
2026-04-27 23:05:28 -04:00
|
|
|
</button>
|
2026-05-03 11:10:03 -04:00
|
|
|
|
|
|
|
|
{/* 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"
|
2026-05-21 01:15:07 -04:00
|
|
|
title="Disable connections with depleted quota on the current page"
|
2026-05-03 11:10:03 -04:00
|
|
|
>
|
|
|
|
|
<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"
|
2026-05-21 01:15:07 -04:00
|
|
|
title="Enable connections that still have quota on the current page"
|
2026-05-03 11:10:03 -04:00
|
|
|
>
|
2026-05-21 01:15:07 -04:00
|
|
|
<span className="material-symbols-outlined text-[14px]">
|
|
|
|
|
check_circle
|
|
|
|
|
</span>
|
2026-05-03 11:10:03 -04:00
|
|
|
<span className="hidden sm:inline">Turn on Available</span>
|
|
|
|
|
</button>
|
|
|
|
|
|
2026-02-03 21:54:11 -05:00
|
|
|
{/* Auto-refresh toggle */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setAutoRefresh((prev) => !prev)}
|
2026-05-03 11:10:03 -04:00
|
|
|
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"
|
2026-02-03 21:54:11 -05:00
|
|
|
title={autoRefresh ? "Disable auto-refresh" : "Enable auto-refresh"}
|
|
|
|
|
>
|
|
|
|
|
<span
|
2026-05-03 11:10:03 -04:00
|
|
|
className={`material-symbols-outlined text-[14px] ${
|
2026-02-03 21:54:11 -05:00
|
|
|
autoRefresh ? "text-primary" : "text-text-muted"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{autoRefresh ? "toggle_on" : "toggle_off"}
|
|
|
|
|
</span>
|
2026-05-21 01:15:07 -04:00
|
|
|
<span className="hidden text-text-primary sm:inline">
|
|
|
|
|
Auto-refresh
|
|
|
|
|
</span>
|
2026-02-03 21:54:11 -05:00
|
|
|
{autoRefresh && (
|
2026-05-21 01:15:07 -04:00
|
|
|
<span className="text-[10px] text-text-muted tabular-nums">
|
|
|
|
|
({countdown}s)
|
|
|
|
|
</span>
|
2026-02-03 21:54:11 -05:00
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{/* Refresh all button */}
|
2026-05-03 11:10:03 -04:00
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-02-03 21:54:11 -05:00
|
|
|
onClick={refreshAll}
|
|
|
|
|
disabled={refreshingAll}
|
2026-05-03 11:10:03 -04:00
|
|
|
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"
|
2026-02-03 21:54:11 -05:00
|
|
|
>
|
2026-05-21 01:15:07 -04:00
|
|
|
<span
|
|
|
|
|
className={`material-symbols-outlined text-[14px] ${refreshingAll ? "animate-spin" : ""}`}
|
|
|
|
|
>
|
|
|
|
|
refresh
|
|
|
|
|
</span>
|
2026-05-03 11:10:03 -04:00
|
|
|
</button>
|
2026-02-03 21:54:11 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-04-04 00:25:58 -04:00
|
|
|
{/* Provider cards: 2 columns, compact */}
|
2026-05-21 01:15:07 -04:00
|
|
|
{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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-04-04 00:25:58 -04:00
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
2026-02-05 06:38:50 -05:00
|
|
|
{sortedConnections.map((conn) => {
|
2026-02-03 21:54:11 -05:00
|
|
|
const quota = quotaData[conn.id];
|
|
|
|
|
const isLoading = loading[conn.id];
|
|
|
|
|
const error = errors[conn.id];
|
|
|
|
|
|
2026-02-05 23:23:08 -05:00
|
|
|
// Use table layout for all providers
|
2026-04-04 00:25:58 -04:00
|
|
|
const isInactive = conn.isActive === false;
|
|
|
|
|
const rowBusy = deletingId === conn.id || togglingId === conn.id;
|
|
|
|
|
|
2026-02-05 23:23:08 -05:00
|
|
|
return (
|
2026-04-04 00:25:58 -04:00
|
|
|
<Card
|
|
|
|
|
key={conn.id}
|
|
|
|
|
padding="none"
|
|
|
|
|
className={`min-w-0 ${isInactive ? "opacity-60" : ""}`}
|
|
|
|
|
>
|
2026-05-03 11:10:03 -04:00
|
|
|
<div className="px-3 py-2 border-b border-black/10 dark:border-white/10">
|
2026-04-04 00:25:58 -04:00
|
|
|
<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">
|
2026-03-22 22:16:10 -04:00
|
|
|
<ProviderIcon
|
2026-02-05 23:23:08 -05:00
|
|
|
src={`/providers/${conn.provider}.png`}
|
|
|
|
|
alt={conn.provider}
|
2026-04-04 00:25:58 -04:00
|
|
|
size={32}
|
2026-02-05 23:23:08 -05:00
|
|
|
className="object-contain"
|
2026-03-22 22:16:10 -04:00
|
|
|
fallbackText={
|
|
|
|
|
conn.provider?.slice(0, 2).toUpperCase() || "PR"
|
|
|
|
|
}
|
2026-02-05 23:23:08 -05:00
|
|
|
/>
|
2026-02-05 06:38:50 -05:00
|
|
|
</div>
|
2026-04-04 00:25:58 -04:00
|
|
|
<div className="min-w-0">
|
|
|
|
|
<h3 className="text-sm font-semibold text-text-primary capitalize truncate">
|
2026-02-05 23:23:08 -05:00
|
|
|
{conn.provider}
|
|
|
|
|
</h3>
|
2026-05-21 01:15:07 -04:00
|
|
|
{getConnectionLabel(conn) ? (
|
|
|
|
|
<p className="text-xs text-text-muted truncate">
|
|
|
|
|
{getConnectionLabel(conn)}
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
2026-02-05 06:38:50 -05:00
|
|
|
</div>
|
2026-02-05 23:23:08 -05:00
|
|
|
</div>
|
2026-03-22 22:16:10 -04:00
|
|
|
|
2026-04-04 00:25:58 -04:00
|
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => refreshProvider(conn.id, conn.provider)}
|
|
|
|
|
disabled={isLoading || rowBusy}
|
2026-05-21 01:15:07 -04:00
|
|
|
aria-label="Refresh quota"
|
2026-04-04 00:25:58 -04:00
|
|
|
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
|
|
|
|
|
title="Refresh quota"
|
2026-03-22 22:16:10 -04:00
|
|
|
>
|
2026-04-04 00:25:58 -04:00
|
|
|
<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}
|
2026-05-21 01:15:07 -04:00
|
|
|
aria-label="Edit connection"
|
2026-04-04 00:25:58 -04:00
|
|
|
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}
|
2026-05-21 01:15:07 -04:00
|
|
|
aria-label="Delete connection"
|
2026-04-04 00:25:58 -04:00
|
|
|
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>
|
2026-02-05 06:38:50 -05:00
|
|
|
</div>
|
2026-02-05 23:23:08 -05:00
|
|
|
</div>
|
|
|
|
|
|
2026-05-03 11:10:03 -04:00
|
|
|
<div className="px-2 py-1.5">
|
2026-02-05 23:23:08 -05:00
|
|
|
{isLoading ? (
|
2026-04-04 00:25:58 -04:00
|
|
|
<div className="text-center py-5 text-text-muted">
|
|
|
|
|
<span className="material-symbols-outlined text-[28px] animate-spin">
|
2026-02-05 23:23:08 -05:00
|
|
|
progress_activity
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : error ? (
|
2026-04-04 00:25:58 -04:00
|
|
|
<div className="text-center py-5">
|
|
|
|
|
<span className="material-symbols-outlined text-[28px] text-red-500">
|
2026-02-05 23:23:08 -05:00
|
|
|
error
|
|
|
|
|
</span>
|
2026-04-04 00:25:58 -04:00
|
|
|
<p className="mt-1.5 text-xs text-text-muted">{error}</p>
|
2026-02-05 23:23:08 -05:00
|
|
|
</div>
|
|
|
|
|
) : quota?.message ? (
|
2026-04-04 00:25:58 -04:00
|
|
|
<div className="text-center py-5">
|
|
|
|
|
<p className="text-xs text-text-muted">{quota.message}</p>
|
2026-02-05 23:23:08 -05:00
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-05-21 01:15:07 -04:00
|
|
|
<QuotaTable
|
|
|
|
|
quotas={quota?.quotas}
|
|
|
|
|
compact
|
|
|
|
|
sortMode="default"
|
|
|
|
|
showSortLabel={
|
|
|
|
|
conn.provider === "codex" && quotaSortMode !== "default"
|
|
|
|
|
}
|
|
|
|
|
/>
|
2026-02-05 23:23:08 -05:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
2026-02-03 21:54:11 -05:00
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
2026-04-04 00:25:58 -04:00
|
|
|
|
2026-05-21 01:15:07 -04:00
|
|
|
<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>
|
|
|
|
|
|
2026-04-04 00:25:58 -04:00
|
|
|
<EditConnectionModal
|
|
|
|
|
isOpen={showEditModal}
|
|
|
|
|
connection={selectedConnection}
|
|
|
|
|
proxyPools={proxyPools}
|
|
|
|
|
onSave={handleUpdateConnection}
|
|
|
|
|
onClose={() => {
|
|
|
|
|
setShowEditModal(false);
|
|
|
|
|
setSelectedConnection(null);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2026-02-03 21:54:11 -05:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|