- Introduced a caching mechanism for in-flight token refresh requests to prevent race conditions and reduce unnecessary API calls. - Added error handling for unrecoverable refresh errors, ensuring that the application can gracefully handle token reuse and invalidation scenarios. - Updated the MITM server management to handle port 443 conflicts, allowing users to kill processes occupying the port before starting the server. - Improved user feedback in the MitmServerCard component regarding port conflicts and admin privileges. - Refactored the ComboList component to streamline the display of media provider combos. This update aims to enhance the reliability and user experience of the token management and MITM functionalities.
771 lines
29 KiB
JavaScript
771 lines
29 KiB
JavaScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import ProviderIcon from "@/shared/components/ProviderIcon";
|
|
import QuotaTable from "./QuotaTable";
|
|
import Toggle from "@/shared/components/Toggle";
|
|
import { parseQuotaData, calculatePercentage } from "./utils";
|
|
import Card from "@/shared/components/Card";
|
|
import { EditConnectionModal } from "@/shared/components";
|
|
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
|
|
|
const REFRESH_INTERVAL_MS = 60000; // 60 seconds
|
|
const DEPLETED_QUOTA_THRESHOLD = 5; // percent
|
|
const AUTO_REFRESH_STORAGE_KEY = "quotaAutoRefresh";
|
|
|
|
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 [lastUpdated, setLastUpdated] = useState(null);
|
|
const [refreshingAll, setRefreshingAll] = useState(false);
|
|
const [countdown, setCountdown] = useState(60);
|
|
const [connectionsLoading, setConnectionsLoading] = useState(true);
|
|
const [deletingId, setDeletingId] = useState(null);
|
|
const [togglingId, setTogglingId] = useState(null);
|
|
const [showEditModal, setShowEditModal] = useState(false);
|
|
const [selectedConnection, setSelectedConnection] = useState(null);
|
|
const [proxyPools, setProxyPools] = useState([]);
|
|
const [providerFilter, setProviderFilter] = useState("all");
|
|
const [expiringFirst, setExpiringFirst] = useState(false);
|
|
const [providerMenuOpen, setProviderMenuOpen] = useState(false);
|
|
const [bulkToggling, setBulkToggling] = useState(false);
|
|
|
|
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 data = await response.json();
|
|
const connectionList = data.connections || [];
|
|
setConnections(connectionList);
|
|
return connectionList;
|
|
} catch (error) {
|
|
console.error("Error fetching connections:", error);
|
|
setConnections([]);
|
|
return [];
|
|
}
|
|
}, []);
|
|
|
|
// Fetch quota for a specific connection
|
|
const fetchQuota = useCallback(async (connectionId, provider) => {
|
|
setLoading((prev) => ({ ...prev, [connectionId]: true }));
|
|
setErrors((prev) => ({ ...prev, [connectionId]: null }));
|
|
|
|
try {
|
|
console.log(
|
|
`[ProviderLimits] Fetching quota for ${provider} (${connectionId})`,
|
|
);
|
|
const response = await fetch(`/api/usage/${connectionId}`);
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
const errorMsg = errorData.error || response.statusText;
|
|
|
|
// Handle different error types gracefully
|
|
if (response.status === 404) {
|
|
// Connection not found - skip silently
|
|
console.warn(
|
|
`[ProviderLimits] Connection not found for ${provider}, skipping`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (response.status === 401) {
|
|
// Auth error - show message instead of throwing
|
|
console.warn(
|
|
`[ProviderLimits] Auth error for ${provider}:`,
|
|
errorMsg,
|
|
);
|
|
setQuotaData((prev) => ({
|
|
...prev,
|
|
[connectionId]: {
|
|
quotas: [],
|
|
message: errorMsg,
|
|
},
|
|
}));
|
|
return;
|
|
}
|
|
|
|
throw new Error(`HTTP ${response.status}: ${errorMsg}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
console.log(`[ProviderLimits] Got quota for ${provider}:`, data);
|
|
|
|
// Parse quota data using provider-specific parser
|
|
const parsedQuotas = parseQuotaData(provider, data);
|
|
|
|
setQuotaData((prev) => ({
|
|
...prev,
|
|
[connectionId]: {
|
|
quotas: parsedQuotas,
|
|
plan: data.plan || null,
|
|
message: data.message || null,
|
|
raw: data,
|
|
},
|
|
}));
|
|
} catch (error) {
|
|
console.error(
|
|
`[ProviderLimits] Error fetching quota for ${provider} (${connectionId}):`,
|
|
error,
|
|
);
|
|
setErrors((prev) => ({
|
|
...prev,
|
|
[connectionId]: error.message || "Failed to fetch quota",
|
|
}));
|
|
} finally {
|
|
setLoading((prev) => ({ ...prev, [connectionId]: false }));
|
|
}
|
|
}, []);
|
|
|
|
// Refresh quota for a specific provider
|
|
const refreshProvider = useCallback(
|
|
async (connectionId, provider) => {
|
|
await fetchQuota(connectionId, provider);
|
|
setLastUpdated(new Date());
|
|
},
|
|
[fetchQuota],
|
|
);
|
|
|
|
const handleDeleteConnection = useCallback(async (id) => {
|
|
if (!confirm("Delete this connection?")) return;
|
|
setDeletingId(id);
|
|
try {
|
|
const res = await fetch(`/api/providers/${id}`, { method: "DELETE" });
|
|
if (res.ok) {
|
|
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 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)),
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error updating connection status:", error);
|
|
} finally {
|
|
setTogglingId(null);
|
|
}
|
|
}, []);
|
|
|
|
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;
|
|
};
|
|
}, []);
|
|
|
|
// Refresh all providers
|
|
const refreshAll = useCallback(async () => {
|
|
if (refreshingAll) return;
|
|
|
|
setRefreshingAll(true);
|
|
setCountdown(60);
|
|
|
|
try {
|
|
const conns = await fetchConnections();
|
|
|
|
// Filter only supported OAuth providers
|
|
const oauthConnections = conns.filter(
|
|
(conn) =>
|
|
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
|
|
conn.authType === "oauth",
|
|
);
|
|
|
|
// Fetch quota for supported OAuth connections only
|
|
await Promise.all(
|
|
oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider)),
|
|
);
|
|
|
|
setLastUpdated(new Date());
|
|
} catch (error) {
|
|
console.error("Error refreshing all providers:", error);
|
|
} finally {
|
|
setRefreshingAll(false);
|
|
}
|
|
}, [refreshingAll, fetchConnections, fetchQuota]);
|
|
|
|
// Initial load: fetch connections first so cards render immediately, then fetch quotas
|
|
useEffect(() => {
|
|
const initializeData = async () => {
|
|
setConnectionsLoading(true);
|
|
const conns = await fetchConnections();
|
|
setConnectionsLoading(false);
|
|
|
|
const oauthConnections = conns.filter(
|
|
(conn) =>
|
|
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
|
|
conn.authType === "oauth",
|
|
);
|
|
|
|
// Mark all as loading before fetching
|
|
const loadingState = {};
|
|
oauthConnections.forEach((conn) => {
|
|
loadingState[conn.id] = true;
|
|
});
|
|
setLoading(loadingState);
|
|
|
|
await Promise.all(
|
|
oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider)),
|
|
);
|
|
setLastUpdated(new Date());
|
|
};
|
|
|
|
initializeData();
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Persist auto-refresh preference
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
window.localStorage.setItem(AUTO_REFRESH_STORAGE_KEY, String(autoRefresh));
|
|
}, [autoRefresh]);
|
|
|
|
// Auto-refresh interval
|
|
useEffect(() => {
|
|
if (!autoRefresh) {
|
|
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);
|
|
};
|
|
}, [autoRefresh, refreshAll]);
|
|
|
|
// 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;
|
|
}
|
|
} else if (autoRefresh) {
|
|
// 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);
|
|
};
|
|
}, [autoRefresh, refreshAll]);
|
|
|
|
// Filter only supported providers
|
|
const filteredConnections = connections.filter(
|
|
(conn) =>
|
|
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
|
|
conn.authType === "oauth",
|
|
);
|
|
|
|
const providerFilteredConnections = filteredConnections.filter(
|
|
(conn) => providerFilter === "all" || conn.provider === providerFilter,
|
|
);
|
|
|
|
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;
|
|
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 }),
|
|
}),
|
|
),
|
|
);
|
|
setConnections((prev) =>
|
|
prev.map((c) => (targetIds.includes(c.id) ? { ...c, isActive } : c)),
|
|
);
|
|
} catch (error) {
|
|
console.error("Error bulk toggling connections:", error);
|
|
} finally {
|
|
setBulkToggling(false);
|
|
}
|
|
},
|
|
[bulkToggling],
|
|
);
|
|
|
|
const handleDisableDepleted = () => {
|
|
const ids = sortedConnections
|
|
.filter((c) => (c.isActive ?? true) && isConnectionDepleted(c))
|
|
.map((c) => c.id);
|
|
bulkSetActive(ids, false);
|
|
};
|
|
|
|
const handleEnableAvailable = () => {
|
|
const ids = sortedConnections
|
|
.filter((c) => !(c.isActive ?? true) && !isConnectionDepleted(c))
|
|
.map((c) => c.id);
|
|
bulkSetActive(ids, true);
|
|
};
|
|
|
|
const providerOptions = Array.from(new Set(filteredConnections.map((conn) => conn.provider))).sort();
|
|
const selectedProviderLabel = providerFilter === "all" ? "All providers" : providerFilter;
|
|
|
|
// 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) {
|
|
return (
|
|
<Card padding="lg">
|
|
<div className="text-center py-12">
|
|
<span className="material-symbols-outlined text-[64px] text-text-muted opacity-20">
|
|
cloud_off
|
|
</span>
|
|
<h3 className="mt-4 text-lg font-semibold text-text-primary">
|
|
No Providers Connected
|
|
</h3>
|
|
<p className="mt-2 text-sm text-text-muted max-w-md mx-auto">
|
|
Connect to providers with OAuth to track your API quota limits and
|
|
usage.
|
|
</p>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header Controls */}
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
|
|
<h2 className="text-xl font-semibold text-text-primary">
|
|
Provider Limits
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setProviderMenuOpen((prev) => !prev)}
|
|
className="flex h-8 items-center justify-between gap-1 rounded-lg border border-black/10 bg-black/[0.02] px-2 text-xs text-text-primary transition-colors hover:bg-black/5 dark:border-white/10 dark:bg-white/[0.03] dark:hover:bg-white/10"
|
|
aria-haspopup="menu"
|
|
aria-expanded={providerMenuOpen}
|
|
title="Filter quota providers"
|
|
>
|
|
<span className="flex min-w-0 items-center gap-1.5">
|
|
{providerFilter === "all" ? (
|
|
<span className="material-symbols-outlined text-[14px] text-text-muted">apps</span>
|
|
) : (
|
|
<ProviderIcon
|
|
src={`/providers/${providerFilter}.png`}
|
|
alt={providerFilter}
|
|
size={18}
|
|
className="size-[18px] rounded object-contain"
|
|
fallbackText={providerFilter.slice(0, 2).toUpperCase()}
|
|
/>
|
|
)}
|
|
<span className="truncate capitalize hidden lg:inline">{selectedProviderLabel}</span>
|
|
</span>
|
|
<span className="material-symbols-outlined text-[14px] text-text-muted">expand_more</span>
|
|
</button>
|
|
|
|
{providerMenuOpen && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="fixed inset-0 z-30 bg-transparent"
|
|
aria-label="Close provider filter"
|
|
onClick={() => setProviderMenuOpen(false)}
|
|
/>
|
|
<div className="absolute left-0 z-40 mt-2 w-64 overflow-hidden rounded-2xl border border-black/10 bg-surface/95 p-1.5 shadow-xl shadow-black/10 backdrop-blur dark:border-white/10 dark:bg-surface/95 sm:w-72">
|
|
<button
|
|
type="button"
|
|
onClick={() => { setProviderFilter("all"); setProviderMenuOpen(false); }}
|
|
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors ${providerFilter === "all" ? "bg-primary/10 text-primary" : "text-text-primary hover:bg-black/5 dark:hover:bg-white/10"}`}
|
|
>
|
|
<span className="material-symbols-outlined text-[22px]">apps</span>
|
|
<span className="font-medium">All providers</span>
|
|
{providerFilter === "all" && <span className="material-symbols-outlined ml-auto text-[20px]">check</span>}
|
|
</button>
|
|
<div className="my-1 h-px bg-black/10 dark:bg-white/10" />
|
|
<div className="max-h-72 overflow-y-auto pr-1">
|
|
{providerOptions.map((provider) => (
|
|
<button
|
|
key={provider}
|
|
type="button"
|
|
onClick={() => { setProviderFilter(provider); setProviderMenuOpen(false); }}
|
|
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors ${providerFilter === provider ? "bg-primary/10 text-primary" : "text-text-primary hover:bg-black/5 dark:hover:bg-white/10"}`}
|
|
>
|
|
<ProviderIcon
|
|
src={`/providers/${provider}.png`}
|
|
alt={provider}
|
|
size={24}
|
|
className="size-6 rounded-md object-contain"
|
|
fallbackText={provider.slice(0, 2).toUpperCase()}
|
|
/>
|
|
<span className="font-medium capitalize">{provider}</span>
|
|
{providerFilter === provider && <span className="material-symbols-outlined ml-auto text-[20px]">check</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpiringFirst((prev) => !prev)}
|
|
className={`flex h-8 shrink-0 items-center gap-1 rounded-lg border px-2 text-xs transition-colors ${expiringFirst ? "border-amber-500/40 bg-amber-500/10 text-amber-500" : "border-black/10 text-text-primary hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/5"}`}
|
|
title="Sort accounts by earliest quota reset time"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">hourglass_top</span>
|
|
<span className="hidden sm:inline">Expiring first</span>
|
|
</button>
|
|
|
|
{/* Bulk: disable depleted */}
|
|
<button
|
|
type="button"
|
|
onClick={handleDisableDepleted}
|
|
disabled={bulkToggling}
|
|
className="flex h-8 shrink-0 items-center gap-1 rounded-lg border border-red-500/30 px-2 text-xs text-red-500 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
|
title="Disable connections with depleted quota (within current filter)"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">block</span>
|
|
<span className="hidden sm:inline">Turn off Empty</span>
|
|
</button>
|
|
|
|
{/* Bulk: enable available */}
|
|
<button
|
|
type="button"
|
|
onClick={handleEnableAvailable}
|
|
disabled={bulkToggling}
|
|
className="flex h-8 shrink-0 items-center gap-1 rounded-lg border border-emerald-500/30 px-2 text-xs text-emerald-500 transition-colors hover:bg-emerald-500/10 disabled:opacity-50"
|
|
title="Enable connections that still have quota (within current filter)"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">check_circle</span>
|
|
<span className="hidden sm:inline">Turn on Available</span>
|
|
</button>
|
|
|
|
{/* Auto-refresh toggle */}
|
|
<button
|
|
onClick={() => setAutoRefresh((prev) => !prev)}
|
|
className="flex h-8 shrink-0 items-center gap-1 rounded-lg border border-black/10 px-2 text-xs transition-colors hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/5"
|
|
title={autoRefresh ? "Disable auto-refresh" : "Enable auto-refresh"}
|
|
>
|
|
<span
|
|
className={`material-symbols-outlined text-[14px] ${
|
|
autoRefresh ? "text-primary" : "text-text-muted"
|
|
}`}
|
|
>
|
|
{autoRefresh ? "toggle_on" : "toggle_off"}
|
|
</span>
|
|
<span className="hidden text-text-primary sm:inline">Auto-refresh</span>
|
|
{autoRefresh && (
|
|
<span className="text-[10px] text-text-muted tabular-nums">({countdown}s)</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* Refresh all button */}
|
|
<button
|
|
type="button"
|
|
onClick={refreshAll}
|
|
disabled={refreshingAll}
|
|
className="flex h-8 shrink-0 items-center gap-1 rounded-lg border border-black/10 px-2 text-xs text-text-primary transition-colors hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/5 disabled:opacity-50"
|
|
title="Refresh all"
|
|
>
|
|
<span className={`material-symbols-outlined text-[14px] ${refreshingAll ? "animate-spin" : ""}`}>refresh</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Provider cards: 2 columns, compact */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
{sortedConnections.map((conn) => {
|
|
const quota = quotaData[conn.id];
|
|
const isLoading = loading[conn.id];
|
|
const error = errors[conn.id];
|
|
|
|
// Use table layout for all providers
|
|
const isInactive = conn.isActive === false;
|
|
const rowBusy = deletingId === conn.id || togglingId === conn.id;
|
|
|
|
return (
|
|
<Card
|
|
key={conn.id}
|
|
padding="none"
|
|
className={`min-w-0 ${isInactive ? "opacity-60" : ""}`}
|
|
>
|
|
<div className="px-3 py-2 border-b border-black/10 dark:border-white/10">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<div className="w-8 h-8 shrink-0 rounded-md flex items-center justify-center overflow-hidden">
|
|
<ProviderIcon
|
|
src={`/providers/${conn.provider}.png`}
|
|
alt={conn.provider}
|
|
size={32}
|
|
className="object-contain"
|
|
fallbackText={
|
|
conn.provider?.slice(0, 2).toUpperCase() || "PR"
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h3 className="text-sm font-semibold text-text-primary capitalize truncate">
|
|
{conn.provider}
|
|
</h3>
|
|
{(() => {
|
|
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 ? (
|
|
<p className="text-xs text-text-muted truncate">{label}</p>
|
|
) : null;
|
|
})()}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => refreshProvider(conn.id, conn.provider)}
|
|
disabled={isLoading || rowBusy}
|
|
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
|
|
title="Refresh quota"
|
|
>
|
|
<span
|
|
className={`material-symbols-outlined text-[18px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
|
|
>
|
|
refresh
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setSelectedConnection(conn);
|
|
setShowEditModal(true);
|
|
}}
|
|
disabled={rowBusy}
|
|
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}
|
|
className="p-1.5 rounded-lg hover:bg-red-500/10 text-red-500 transition-colors disabled:opacity-50"
|
|
title="Delete connection"
|
|
>
|
|
<span
|
|
className={`material-symbols-outlined text-[18px] ${deletingId === conn.id ? "animate-pulse" : ""}`}
|
|
>
|
|
delete
|
|
</span>
|
|
</button>
|
|
<div
|
|
className="inline-flex items-center pl-0.5"
|
|
title={
|
|
(conn.isActive ?? true)
|
|
? "Disable connection"
|
|
: "Enable connection"
|
|
}
|
|
>
|
|
<Toggle
|
|
size="sm"
|
|
checked={conn.isActive ?? true}
|
|
disabled={rowBusy}
|
|
onChange={(nextActive) =>
|
|
handleToggleConnectionActive(conn.id, nextActive)
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-2 py-1.5">
|
|
{isLoading ? (
|
|
<div className="text-center py-5 text-text-muted">
|
|
<span className="material-symbols-outlined text-[28px] animate-spin">
|
|
progress_activity
|
|
</span>
|
|
</div>
|
|
) : error ? (
|
|
<div className="text-center py-5">
|
|
<span className="material-symbols-outlined text-[28px] text-red-500">
|
|
error
|
|
</span>
|
|
<p className="mt-1.5 text-xs text-text-muted">{error}</p>
|
|
</div>
|
|
) : quota?.message ? (
|
|
<div className="text-center py-5">
|
|
<p className="text-xs text-text-muted">{quota.message}</p>
|
|
</div>
|
|
) : (
|
|
<QuotaTable quotas={quota?.quotas} compact />
|
|
)}
|
|
</div>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<EditConnectionModal
|
|
isOpen={showEditModal}
|
|
connection={selectedConnection}
|
|
proxyPools={proxyPools}
|
|
onSave={handleUpdateConnection}
|
|
onClose={() => {
|
|
setShowEditModal(false);
|
|
setSelectedConnection(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|