From 6b0dc0923996e091a7a3a08d4947c6a54f377922 Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 21 May 2026 12:15:07 +0700 Subject: [PATCH] Merge PR #1300: tailscale Windows fix, quota pagination, SSE abort handling - fix(tunnel): cross-platform tailscale probes without shell redirection - feat(usage): paginate provider limits with page size controls - feat(providers): stop control for one-by-one connection testing - fix(sse): close stream gracefully on abort/disconnect instead of pipe errors - ui(quota): simplify header, always show pagination in one row Co-authored-by: philau2512 Co-authored-by: Cursor --- open-sse/utils/streamHandler.js | 14 +- .../dashboard/providers/[id]/ConnectionRow.js | 28 +- .../dashboard/providers/[id]/page.js | 147 ++- .../components/ProviderLimits/QuotaTable.js | 274 ++++-- .../usage/components/ProviderLimits/index.js | 905 ++++++++++++++---- .../usage/components/ProviderLimits/utils.js | 18 + src/app/api/providers/client/route.js | 86 +- src/lib/tunnel/tailscale.js | 6 +- 8 files changed, 1183 insertions(+), 295 deletions(-) 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) */} - + + - {/* Reset Time */} - + + - - ); - })} - -
-
- {/* Progress bar - always show with border for visibility */} -
-
-
- - {/* Numbers */} -
- - {quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"} - - - {remaining}% + return ( +
+
+ {colors.emoji} + + {quota.name}
- -
- {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);