diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 7228fdd51e..9e37aafe54 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -1498,11 +1498,6 @@ "count": 1 } }, - "src/app/(dashboard)/dashboard/settings/components/AccessTokensTab.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx": { "@next/next/no-img-element": { "count": 4 @@ -1511,9 +1506,6 @@ "src/app/(dashboard)/dashboard/settings/components/AuthzSection.tsx": { "no-restricted-syntax": { "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 } }, "src/app/(dashboard)/dashboard/settings/components/CliproxyapiSettingsTab.tsx": { @@ -1531,22 +1523,9 @@ "count": 1 } }, - "src/app/(dashboard)/dashboard/settings/components/FallbackChainsEditor.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/settings/components/IPFilterSection.tsx": { - "react-hooks/immutability": { - "count": 1 - } - }, "src/app/(dashboard)/dashboard/settings/components/MitmProxyTab.tsx": { "@next/next/no-html-link-for-pages": { "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 } }, "src/app/(dashboard)/dashboard/settings/components/ModelAliasesTab.tsx": { @@ -1557,9 +1536,6 @@ "src/app/(dashboard)/dashboard/settings/components/ModelCapabilityOverridesTab.tsx": { "@typescript-eslint/no-unused-vars": { "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 } }, "src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx": { @@ -1567,59 +1543,16 @@ "count": 1 } }, - "src/app/(dashboard)/dashboard/settings/components/ModelsDevSyncTab.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/settings/components/OneproxyTab.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/settings/components/PayloadRulesTab.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/settings/components/PoliciesPanel.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": { - "react-hooks/set-state-in-effect": { - "count": 2 - } - }, - "src/app/(dashboard)/dashboard/settings/components/ProviderAccountRoutingCard.tsx": { - "react-hooks/exhaustive-deps": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": { "@typescript-eslint/no-unused-vars": { "count": 3 } }, - "src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": { - "react-hooks/set-state-in-effect": { - "count": 8 - } - }, "src/app/(dashboard)/dashboard/settings/components/ResponsesStatePolicyTab.tsx": { "@typescript-eslint/no-unused-vars": { "count": 1 } }, - "src/app/(dashboard)/dashboard/settings/components/RoutingStrategyCard.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": { "@typescript-eslint/no-unused-vars": { "count": 1 @@ -1633,19 +1566,6 @@ "src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": { "@typescript-eslint/no-unused-vars": { "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/settings/components/proxy/GlobalConfigTab.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/settings/components/proxy/SubscriptionTab.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 } }, "src/app/(dashboard)/dashboard/settings/components/useProxyPoolModal.ts": { diff --git a/src/app/(dashboard)/dashboard/settings/components/AccessTokensTab.tsx b/src/app/(dashboard)/dashboard/settings/components/AccessTokensTab.tsx index d4e9b88f9b..e5b350bab0 100644 --- a/src/app/(dashboard)/dashboard/settings/components/AccessTokensTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/AccessTokensTab.tsx @@ -21,11 +21,21 @@ const SCOPE_VARIANT: Record = admin: "error", }; +async function fetchTokens(): Promise { + const res = await fetch("/api/cli/tokens"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + return Array.isArray(data.tokens) ? data.tokens : []; +} + export default function AccessTokensTab() { const t = useTranslations("settings"); // Graceful fallback so the tab renders in every locale before keys are translated. - const L = (key: string, fallback: string) => - typeof t.has === "function" && t.has(key) ? t(key) : fallback; + const L = useCallback( + (key: string, fallback: string) => + typeof t.has === "function" && t.has(key) ? t(key) : fallback, + [t] + ); const [tokens, setTokens] = useState([]); const [loading, setLoading] = useState(true); @@ -44,20 +54,30 @@ export default function AccessTokensTab() { setLoading(true); setError(""); try { - const res = await fetch("/api/cli/tokens"); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - setTokens(Array.isArray(data.tokens) ? data.tokens : []); + setTokens(await fetchTokens()); } catch { setError(L("accessTokensLoadError", "Could not load access tokens.")); } finally { setLoading(false); } - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, [L]); useEffect(() => { - load(); - }, [load]); + let cancelled = false; + void (async () => { + try { + const tokens = await fetchTokens(); + if (!cancelled) setTokens(tokens); + } catch { + if (!cancelled) setError(L("accessTokensLoadError", "Could not load access tokens.")); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [L]); const createToken = async () => { if (!name.trim()) return; @@ -83,7 +103,9 @@ export default function AccessTokensTab() { setScope("read"); await load(); } catch (e) { - setError(e instanceof Error ? e.message : L("accessTokensCreateError", "Could not create token.")); + setError( + e instanceof Error ? e.message : L("accessTokensCreateError", "Could not create token.") + ); } finally { setCreating(false); } @@ -161,7 +183,9 @@ export default function AccessTokensTab() { onChange={(e) => setExpires(e.target.value)} /> @@ -213,10 +237,18 @@ export default function AccessTokensTab() { {L("accessTokensColName", "Name")} {L("accessTokensColScope", "Scope")} - {L("accessTokensColPrefix", "Prefix")} - {L("accessTokensColStatus", "Status")} - {L("accessTokensColLastUsed", "Last used")} - {L("accessTokensColExpires", "Expires")} + + {L("accessTokensColPrefix", "Prefix")} + + + {L("accessTokensColStatus", "Status")} + + + {L("accessTokensColLastUsed", "Last used")} + + + {L("accessTokensColExpires", "Expires")} + diff --git a/src/app/(dashboard)/dashboard/settings/components/AuthzSection.tsx b/src/app/(dashboard)/dashboard/settings/components/AuthzSection.tsx index f05e4a7c92..88cd98729b 100644 --- a/src/app/(dashboard)/dashboard/settings/components/AuthzSection.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/AuthzSection.tsx @@ -93,6 +93,25 @@ function parseErrorCode(payload: unknown): ErrorCode { // ─── component ──────────────────────────────────────────────────────────── +type InventoryFetchResult = + | { kind: "ok"; data: InventoryPayload } + | { kind: "http-error"; code: ErrorCode } + | { kind: "failed" }; + +async function fetchInventory(): Promise { + try { + const res = await fetch("/api/settings/authz-inventory", { + credentials: "include", + }); + if (!res.ok) { + return { kind: "http-error", code: parseErrorCode(await res.json().catch(() => null)) }; + } + return { kind: "ok", data: (await res.json()) as InventoryPayload }; + } catch { + return { kind: "failed" }; + } +} + export default function AuthzSection() { const t = useTranslations("settings"); const [inventory, setInventory] = useState(null); @@ -110,32 +129,38 @@ export default function AuthzSection() { const [submitting, setSubmitting] = useState(false); const [status, setStatus] = useState(null); + const applyInventoryResult = useCallback( + (result: InventoryFetchResult) => { + if (result.kind === "ok") { + setInventory(result.data); + setDraftEnabled(result.data.bypassEnabled); + setDraftPrefixes([...result.data.bypassPrefixes]); + } else if (result.kind === "http-error") { + setLoadError(t(`authz.error.${result.code}`)); + } else { + setLoadError(t("authz.loadError")); + } + setLoading(false); + }, + [t] + ); + const loadInventory = useCallback(async () => { setLoading(true); setLoadError(null); - try { - const res = await fetch("/api/settings/authz-inventory", { - credentials: "include", - }); - if (!res.ok) { - const code = parseErrorCode(await res.json().catch(() => null)); - setLoadError(t(`authz.error.${code}`)); - return; - } - const data = (await res.json()) as InventoryPayload; - setInventory(data); - setDraftEnabled(data.bypassEnabled); - setDraftPrefixes([...data.bypassPrefixes]); - } catch { - setLoadError(t("authz.loadError")); - } finally { - setLoading(false); - } - }, [t]); + applyInventoryResult(await fetchInventory()); + }, [applyInventoryResult]); useEffect(() => { - void loadInventory(); - }, [loadInventory]); + let cancelled = false; + void (async () => { + const result = await fetchInventory(); + if (!cancelled) applyInventoryResult(result); + })(); + return () => { + cancelled = true; + }; + }, [applyInventoryResult]); const dirty = useMemo(() => { if (!inventory) return false; diff --git a/src/app/(dashboard)/dashboard/settings/components/FallbackChainsEditor.tsx b/src/app/(dashboard)/dashboard/settings/components/FallbackChainsEditor.tsx index 17cd007372..171c92ffc2 100644 --- a/src/app/(dashboard)/dashboard/settings/components/FallbackChainsEditor.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/FallbackChainsEditor.tsx @@ -24,6 +24,19 @@ const CHAIN_COLORS = [ "#14b8a6", ]; +async function fetchChainsData() { + try { + const res = await fetch("/api/fallback/chains"); + if (res.ok) { + const data = await res.json(); + return data.chains || data || {}; + } + } catch { + // silent + } + return null; +} + export default function FallbackChainsEditor() { const [chains, setChains] = useState({}); const [loading, setLoading] = useState(true); @@ -35,23 +48,25 @@ export default function FallbackChainsEditor() { const t = useTranslations("settings"); const tc = useTranslations("common"); - const fetchChains = useCallback(async () => { - try { - const res = await fetch("/api/fallback/chains"); - if (res.ok) { - const data = await res.json(); - setChains(data.chains || data || {}); - } - } catch { - // silent - } finally { - setLoading(false); - } + const applyChains = useCallback((data) => { + if (data) setChains(data); + setLoading(false); }, []); + const fetchChains = useCallback(async () => { + applyChains(await fetchChainsData()); + }, [applyChains]); + useEffect(() => { - fetchChains(); - }, [fetchChains]); + let cancelled = false; + void (async () => { + const data = await fetchChainsData(); + if (!cancelled) applyChains(data); + })(); + return () => { + cancelled = true; + }; + }, [applyChains]); const handleCreate = async () => { if (!newModel.trim() || !newProviders.trim()) { diff --git a/src/app/(dashboard)/dashboard/settings/components/IPFilterSection.tsx b/src/app/(dashboard)/dashboard/settings/components/IPFilterSection.tsx index faeb5eb93f..a6b7091658 100644 --- a/src/app/(dashboard)/dashboard/settings/components/IPFilterSection.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/IPFilterSection.tsx @@ -25,19 +25,24 @@ export default function IPFilterSection() { const t = useTranslations("settings"); useEffect(() => { - loadConfig(); + let cancelled = false; + void (async () => { + try { + const res = await fetch("/api/settings/ip-filter"); + if (res.ok) { + const data = await res.json(); + if (!cancelled) setConfig(data); + } + } catch { + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; }, []); - const loadConfig = async () => { - try { - const res = await fetch("/api/settings/ip-filter"); - if (res.ok) setConfig(await res.json()); - } catch { - } finally { - setLoading(false); - } - }; - const updateConfig = async (updates) => { try { const res = await fetch("/api/settings/ip-filter", { diff --git a/src/app/(dashboard)/dashboard/settings/components/MitmProxyTab.tsx b/src/app/(dashboard)/dashboard/settings/components/MitmProxyTab.tsx index 29f2d44c24..59fc298ca7 100644 --- a/src/app/(dashboard)/dashboard/settings/components/MitmProxyTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/MitmProxyTab.tsx @@ -63,6 +63,19 @@ function formatDate(value: string | null) { } } +type MitmStatusResult = { data: MitmStatus } | { errorMessage: string | null }; + +async function fetchMitmStatusResult(): Promise { + try { + const response = await fetch("/api/settings/mitm"); + const data = await response.json().catch(() => ({})); + if (!response.ok) return { errorMessage: data.error ? String(data.error) : null }; + return { data: data as MitmStatus }; + } catch (error) { + return { errorMessage: error instanceof Error ? error.message : null }; + } +} + export default function MitmProxyTab() { const t = useTranslations("mitm"); const [status, setStatus] = useState(emptyStatus); @@ -75,28 +88,38 @@ export default function MitmProxyTab() { null ); + const applyStatusResult = useCallback( + (result: MitmStatusResult) => { + if ("data" in result) { + setStatus(result.data); + setPort(String(TRANSPARENT_MITM_PORT)); + setFeedback(null); + } else { + setFeedback({ + type: "error", + message: result.errorMessage ?? t("loadFailed"), + }); + } + setLoading(false); + }, + [t] + ); + const loadStatus = useCallback(async () => { setLoading(true); - try { - const response = await fetch("/api/settings/mitm"); - const data = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(data.error || t("loadFailed")); - setStatus(data); - setPort(String(TRANSPARENT_MITM_PORT)); - setFeedback(null); - } catch (error) { - setFeedback({ - type: "error", - message: error instanceof Error ? error.message : t("loadFailed"), - }); - } finally { - setLoading(false); - } - }, [t]); + applyStatusResult(await fetchMitmStatusResult()); + }, [applyStatusResult]); useEffect(() => { - void loadStatus(); - }, [loadStatus]); + let cancelled = false; + void (async () => { + const result = await fetchMitmStatusResult(); + if (!cancelled) applyStatusResult(result); + })(); + return () => { + cancelled = true; + }; + }, [applyStatusResult]); const updateMitm = async (payload: Record, successMessage: string) => { setSaving(true); diff --git a/src/app/(dashboard)/dashboard/settings/components/ModelCapabilityOverridesTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ModelCapabilityOverridesTab.tsx index e0b0ee66ab..8903b7c9b8 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ModelCapabilityOverridesTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ModelCapabilityOverridesTab.tsx @@ -50,27 +50,6 @@ function useModelCapabilityOverridesData() { window.setTimeout(() => setStatusMessage(null), 4000); }, []); - const loadData = useCallback(async () => { - setLoading(true); - try { - const [catalogRes, overridesRes] = await Promise.all([ - fetch("/api/pricing/models"), - fetch("/api/model-capability-overrides"), - ]); - if (catalogRes.ok) - setCatalog((await catalogRes.json()) as Record); - if (overridesRes.ok) { - const payload = (await overridesRes.json()) as { overrides?: ModelCapabilityOverride[] }; - setOverrides(payload.overrides || []); - } - } catch (error) { - console.error("Failed to load model capability overrides:", error); - showStatus("error", t("modelOverrideLoadFailed")); - } finally { - setLoading(false); - } - }, [showStatus, t]); - const saveOverride = useCallback( async (target: string, key: ModelOverrideKey, value: number | string) => { try { @@ -109,8 +88,35 @@ function useModelCapabilityOverridesData() { ); useEffect(() => { - void loadData(); - }, [loadData]); + let cancelled = false; + void (async () => { + try { + const [catalogRes, overridesRes] = await Promise.all([ + fetch("/api/pricing/models"), + fetch("/api/model-capability-overrides"), + ]); + if (catalogRes.ok) { + const catalogPayload = (await catalogRes.json()) as Record< + string, + PricingCatalogProvider + >; + if (!cancelled) setCatalog(catalogPayload); + } + if (overridesRes.ok) { + const payload = (await overridesRes.json()) as { overrides?: ModelCapabilityOverride[] }; + if (!cancelled) setOverrides(payload.overrides || []); + } + } catch (error) { + console.error("Failed to load model capability overrides:", error); + if (!cancelled) showStatus("error", t("modelOverrideLoadFailed")); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [showStatus, t]); return { catalog, overrides, loading, statusMessage, saveOverride, removeOverride }; } diff --git a/src/app/(dashboard)/dashboard/settings/components/ModelsDevSyncTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ModelsDevSyncTab.tsx index 0a8a17cb92..d21b251a09 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ModelsDevSyncTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ModelsDevSyncTab.tsx @@ -64,6 +64,16 @@ function formatInterval(hours: number): string { return hours === 168 ? "7d" : `${hours}h`; } +async function fetchSyncStatusData(): Promise { + try { + const res = await fetch("/api/settings/models-dev?action=status"); + if (res.ok) return (await res.json()) as ModelsDevStatus; + } catch { + // Silently fail — sync may not be initialized yet + } + return null; +} + export default function ModelsDevSyncTab() { const t = useTranslations("settings"); const [status, setStatus] = useState(null); @@ -78,21 +88,21 @@ export default function ModelsDevSyncTab() { ); const fetchStatus = useCallback(async () => { - try { - const res = await fetch("/api/settings/models-dev?action=status"); - if (res.ok) { - const data = await res.json(); - setStatus(data); - } - } catch { - // Silently fail — sync may not be initialized yet - } + const data = await fetchSyncStatusData(); + if (data) setStatus(data); setLoading(false); }, []); useEffect(() => { - Promise.all([fetchStatus(), fetch("/api/settings").then((r) => (r.ok ? r.json() : null))]) - .then(([, settingsData]) => { + let cancelled = false; + void (async () => { + try { + const [statusData, settingsData] = await Promise.all([ + fetchSyncStatusData(), + fetch("/api/settings").then((r) => (r.ok ? r.json() : null)), + ]); + if (cancelled) return; + if (statusData) setStatus(statusData); if (settingsData) { setEnabled(settingsData.modelsDevSyncEnabled === true); const intervalMs = settingsData.modelsDevSyncInterval || 86400000; @@ -100,12 +110,17 @@ export default function ModelsDevSyncTab() { setIntervalHours(hours); setDraftPos(hoursToPosition(hours)); } - }) - .catch((err) => { + } catch (err) { console.error("Failed to fetch models.dev settings:", err); - setFeedback({ type: "error", message: "Failed to load settings" }); - }); - }, [fetchStatus]); + if (!cancelled) setFeedback({ type: "error", message: "Failed to load settings" }); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); const triggerSync = async () => { setSyncing(true); diff --git a/src/app/(dashboard)/dashboard/settings/components/OneproxyTab.tsx b/src/app/(dashboard)/dashboard/settings/components/OneproxyTab.tsx index 52465ba0ae..b985fc5fa2 100644 --- a/src/app/(dashboard)/dashboard/settings/components/OneproxyTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/OneproxyTab.tsx @@ -36,6 +36,42 @@ type SyncStatus = { consecutiveFailures: number; }; +type OneproxyFetchResult = { + proxies?: OneproxyItem[]; + statsPayload?: { stats: OneproxyStats; status: SyncStatus }; +}; + +async function fetchOneproxyData( + filterProtocol: string, + filterCountry: string, + minQuality: string +): Promise { + const result: OneproxyFetchResult = {}; + try { + const params = new URLSearchParams(); + if (filterProtocol) params.set("protocol", filterProtocol); + if (filterCountry) params.set("countryCode", filterCountry); + if (minQuality) params.set("minQuality", minQuality); + + const [proxiesRes, statsRes] = await Promise.all([ + fetch(`/api/settings/oneproxy?${params.toString()}`), + fetch("/api/settings/oneproxy?action=stats"), + ]); + + if (proxiesRes.ok) { + const data = await proxiesRes.json(); + result.proxies = data.items || []; + } + if (statsRes.ok) { + const data = await statsRes.json(); + result.statsPayload = { stats: data.stats, status: data.status }; + } + } catch { + /* silent */ + } + return result; +} + export default function OneproxyTab() { const t = useTranslations("settings"); const [proxies, setProxies] = useState([]); @@ -49,37 +85,37 @@ export default function OneproxyTab() { const [filterCountry, setFilterCountry] = useState(""); const [minQuality, setMinQuality] = useState(""); + const applyData = useCallback((result: OneproxyFetchResult) => { + if (result.proxies) setProxies(result.proxies); + if (result.statsPayload) { + setStats(result.statsPayload.stats); + setStatus(result.statsPayload.status); + } + setLoading(false); + }, []); + const loadData = useCallback(async () => { setLoading(true); - try { - const params = new URLSearchParams(); - if (filterProtocol) params.set("protocol", filterProtocol); - if (filterCountry) params.set("countryCode", filterCountry); - if (minQuality) params.set("minQuality", minQuality); + applyData(await fetchOneproxyData(filterProtocol, filterCountry, minQuality)); + }, [applyData, filterProtocol, filterCountry, minQuality]); - const [proxiesRes, statsRes] = await Promise.all([ - fetch(`/api/settings/oneproxy?${params.toString()}`), - fetch("/api/settings/oneproxy?action=stats"), - ]); - - if (proxiesRes.ok) { - const data = await proxiesRes.json(); - setProxies(data.items || []); - } - if (statsRes.ok) { - const data = await statsRes.json(); - setStats(data.stats); - setStatus(data.status); - } - } catch { - } finally { - setLoading(false); - } - }, [filterProtocol, filterCountry, minQuality]); + const filtersKey = `${filterProtocol}|${filterCountry}|${minQuality}`; + const [prevFiltersKey, setPrevFiltersKey] = useState(filtersKey); + if (prevFiltersKey !== filtersKey) { + setPrevFiltersKey(filtersKey); + setLoading(true); + } useEffect(() => { - loadData(); - }, [loadData]); + let cancelled = false; + void (async () => { + const result = await fetchOneproxyData(filterProtocol, filterCountry, minQuality); + if (!cancelled) applyData(result); + })(); + return () => { + cancelled = true; + }; + }, [applyData, filterProtocol, filterCountry, minQuality]); const handleSync = async () => { setSyncing(true); diff --git a/src/app/(dashboard)/dashboard/settings/components/PayloadRulesTab.tsx b/src/app/(dashboard)/dashboard/settings/components/PayloadRulesTab.tsx index 0e51c92768..3e411494bd 100644 --- a/src/app/(dashboard)/dashboard/settings/components/PayloadRulesTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/PayloadRulesTab.tsx @@ -55,6 +55,21 @@ function getErrorMessage(payload: unknown, fallback: string): string { return fallback; } +async function fetchPayloadRulesResult( + fallbackMessage: string +): Promise<{ text?: string; error?: string }> { + try { + const response = await fetch("/api/settings/payload-rules"); + const payload = await response.json(); + if (!response.ok) { + throw new Error(getErrorMessage(payload, fallbackMessage)); + } + return { text: JSON.stringify(payload, null, 2) }; + } catch (error) { + return { error: error instanceof Error ? error.message : fallbackMessage }; + } +} + export default function PayloadRulesTab() { const t = useTranslations("settings"); const tCommon = useTranslations("common"); @@ -88,30 +103,28 @@ export default function PayloadRulesTab() { }; }, [parsedEditor.value]); + const applyConfigResult = useCallback((result: { text?: string; error?: string }) => { + if (result.text !== undefined) setEditorValue(result.text); + if (result.error !== undefined) setMessage({ type: "error", text: result.error }); + setLoading(false); + }, []); + const loadConfig = useCallback(async () => { setLoading(true); setMessage(null); - try { - const response = await fetch("/api/settings/payload-rules"); - const payload = await response.json(); - if (!response.ok) { - throw new Error(getErrorMessage(payload, tCommon("failedToLoad"))); - } - - setEditorValue(JSON.stringify(payload, null, 2)); - } catch (error) { - setMessage({ - type: "error", - text: error instanceof Error ? error.message : tCommon("failedToLoad"), - }); - } finally { - setLoading(false); - } - }, [tCommon]); + applyConfigResult(await fetchPayloadRulesResult(tCommon("failedToLoad"))); + }, [applyConfigResult, tCommon]); useEffect(() => { - loadConfig(); - }, [loadConfig]); + let cancelled = false; + void (async () => { + const result = await fetchPayloadRulesResult(tCommon("failedToLoad")); + if (!cancelled) applyConfigResult(result); + })(); + return () => { + cancelled = true; + }; + }, [applyConfigResult, tCommon]); const handleReset = () => { setEditorValue(EMPTY_PAYLOAD_RULES_TEXT); diff --git a/src/app/(dashboard)/dashboard/settings/components/PoliciesPanel.tsx b/src/app/(dashboard)/dashboard/settings/components/PoliciesPanel.tsx index 7f980c1d86..16b67902a9 100644 --- a/src/app/(dashboard)/dashboard/settings/components/PoliciesPanel.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/PoliciesPanel.tsx @@ -13,6 +13,16 @@ import { Card, Button } from "@/shared/components"; import { useNotificationStore } from "@/store/notificationStore"; import { useTranslations } from "next-intl"; +async function fetchPoliciesData() { + try { + const res = await fetch("/api/policies"); + if (res.ok) return await res.json(); + } catch { + // silent + } + return null; +} + export default function PoliciesPanel() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -20,25 +30,27 @@ export default function PoliciesPanel() { const notify = useNotificationStore(); const t = useTranslations("settings"); - const fetchPolicies = useCallback(async () => { - try { - const res = await fetch("/api/policies"); - if (res.ok) { - const json = await res.json(); - setData(json); - } - } catch { - // silent - } finally { - setLoading(false); - } + const applyPolicies = useCallback((json) => { + if (json) setData(json); + setLoading(false); }, []); + const fetchPolicies = useCallback(async () => { + applyPolicies(await fetchPoliciesData()); + }, [applyPolicies]); + useEffect(() => { - fetchPolicies(); + let cancelled = false; + void (async () => { + const json = await fetchPoliciesData(); + if (!cancelled) applyPolicies(json); + })(); const interval = setInterval(fetchPolicies, 15000); - return () => clearInterval(interval); - }, [fetchPolicies]); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [applyPolicies, fetchPolicies]); const handleUnlock = async (identifier) => { setUnlocking(identifier); diff --git a/src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx index cafd2f45aa..c99177cd31 100644 --- a/src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx @@ -67,6 +67,37 @@ function getSourceTone(source: PricingSource): string { } } +type PricingBundle = { + catalog?: Record; + pricing?: Record>>; + sources?: Record>; + sync?: SyncStatus; +}; + +async function fetchPricingBundle(): Promise { + const [catalogRes, pricingRes, syncRes] = await Promise.all([ + fetch("/api/pricing/models"), + fetch("/api/pricing?includeSources=1"), + fetch("/api/pricing/sync"), + ]); + const bundle: PricingBundle = {}; + if (catalogRes.ok) { + bundle.catalog = (await catalogRes.json()) as Record; + } + if (pricingRes.ok) { + const pricingPayload = (await pricingRes.json()) as { + pricing?: Record>>; + sourceMap?: Record>; + }; + bundle.pricing = pricingPayload.pricing || {}; + bundle.sources = pricingPayload.sourceMap || {}; + } + if (syncRes.ok) { + bundle.sync = (await syncRes.json()) as SyncStatus; + } + return bundle; +} + export default function PricingTab() { const [catalog, setCatalog] = useState>({}); const [pricingData, setPricingData] = useState< @@ -98,42 +129,43 @@ export default function PricingTab() { window.setTimeout(() => setStatusMessage(null), 4000); }, []); + const applyPricingBundle = useCallback((bundle: PricingBundle) => { + if (bundle.catalog) setCatalog(bundle.catalog); + if (bundle.pricing) setPricingData(bundle.pricing); + if (bundle.sources) setPricingSources(bundle.sources); + if (bundle.sync) setSyncStatus(bundle.sync); + setLoading(false); + }, []); + const loadData = useCallback(async () => { setLoading(true); try { - const [catalogRes, pricingRes, syncRes] = await Promise.all([ - fetch("/api/pricing/models"), - fetch("/api/pricing?includeSources=1"), - fetch("/api/pricing/sync"), - ]); - - if (catalogRes.ok) { - setCatalog((await catalogRes.json()) as Record); - } - - if (pricingRes.ok) { - const pricingPayload = (await pricingRes.json()) as { - pricing?: Record>>; - sourceMap?: Record>; - }; - setPricingData(pricingPayload.pricing || {}); - setPricingSources(pricingPayload.sourceMap || {}); - } - - if (syncRes.ok) { - setSyncStatus((await syncRes.json()) as SyncStatus); - } + applyPricingBundle(await fetchPricingBundle()); } catch (error) { console.error("Failed to load pricing data:", error); showStatus("error", t("pricingLoadFailed")); - } finally { setLoading(false); } - }, [showStatus, t]); + }, [applyPricingBundle, showStatus, t]); useEffect(() => { - void loadData(); - }, [loadData]); + let cancelled = false; + void (async () => { + try { + const bundle = await fetchPricingBundle(); + if (!cancelled) applyPricingBundle(bundle); + } catch (error) { + console.error("Failed to load pricing data:", error); + if (!cancelled) { + showStatus("error", t("pricingLoadFailed")); + setLoading(false); + } + } + })(); + return () => { + cancelled = true; + }; + }, [applyPricingBundle, showStatus, t]); const allProviders = useMemo(() => { return Object.entries(catalog) @@ -216,10 +248,13 @@ export default function PricingTab() { [allProviders] ); - // Reset visible count when filters change - useEffect(() => { + // Reset visible count when filters change (state adjustment during render) + const filtersKey = `${searchQuery}|${coverageFilter}|${authFilter}|${sortKey}`; + const [prevFiltersKey, setPrevFiltersKey] = useState(filtersKey); + if (prevFiltersKey !== filtersKey) { + setPrevFiltersKey(filtersKey); setVisibleCount(INITIAL_VISIBLE); - }, [searchQuery, coverageFilter, authFilter, sortKey]); + } const stats = useMemo(() => { const totalModels = allProviders.reduce((sum, provider) => sum + provider.modelCount, 0); diff --git a/src/app/(dashboard)/dashboard/settings/components/ProviderAccountRoutingCard.tsx b/src/app/(dashboard)/dashboard/settings/components/ProviderAccountRoutingCard.tsx index 298e1dc147..a1ced6625d 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ProviderAccountRoutingCard.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ProviderAccountRoutingCard.tsx @@ -24,6 +24,19 @@ function clampProviderStickyLimit(raw: string): number { return Math.min(1000, Math.max(1, Number.isNaN(val) ? 3 : val)); } +async function fetchProviderOverride( + providerKey: string +): Promise<{ override: ProviderStrategyOverride | undefined } | null> { + const res = await fetch("/api/settings", { cache: "no-store" }); + if (!res.ok) return null; + const data = await res.json(); + return { + override: ((data?.providerStrategies || {}) as Record)[ + providerKey + ], + }; +} + /** Loads/saves the per-provider account-routing override. Extracted out of the * component body to keep ProviderAccountRoutingCard's own render function small. */ function useProviderAccountRoutingState(providerKey: string) { @@ -31,22 +44,28 @@ function useProviderAccountRoutingState(providerKey: string) { const [stickyLimit, setStickyLimit] = useState("3"); const [busy, setBusy] = useState(false); - const load = useCallback(async () => { - const res = await fetch("/api/settings", { cache: "no-store" }); - if (!res.ok) return; - const data = await res.json(); - const override = ((data?.providerStrategies || {}) as Record)[ - providerKey - ]; + const applyOverride = useCallback((override: ProviderStrategyOverride | undefined) => { setStrategy(override?.fallbackStrategy || ""); setStickyLimit( override?.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : "" ); - }, [providerKey]); + }, []); + + const load = useCallback(async () => { + const result = await fetchProviderOverride(providerKey); + if (result) applyOverride(result.override); + }, [applyOverride, providerKey]); useEffect(() => { - load().catch(console.error); - }, [load]); + let cancelled = false; + void (async () => { + const result = await fetchProviderOverride(providerKey); + if (!cancelled && result) applyOverride(result.override); + })().catch(console.error); + return () => { + cancelled = true; + }; + }, [applyOverride, providerKey]); const save = useCallback( async (nextStrategy: string, nextSticky: string) => { @@ -84,7 +103,7 @@ function useProviderAccountRoutingState(providerKey: string) { setBusy(false); } }, - [providerKey] + [load, providerKey] ); return { strategy, setStrategy, stickyLimit, setStickyLimit, busy, save }; @@ -134,4 +153,4 @@ export default function ProviderAccountRoutingCard({ providerKey, connectionCoun ); -} \ No newline at end of file +} diff --git a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx index 6aea2986f3..7c066a7d88 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx @@ -188,10 +188,12 @@ function RequestQueueCard({ const t = useTranslations("settings"); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); + const [prevValue, setPrevValue] = useState(value); - useEffect(() => { + if (prevValue !== value) { + setPrevValue(value); setDraft(value); - }, [value]); + } return ( @@ -277,7 +279,9 @@ function RequestQueueCard({ value={draft.executionMaxWaitMs} min={1} suffix="ms" - onChange={(executionMaxWaitMs) => setDraft((prev) => ({ ...prev, executionMaxWaitMs }))} + onChange={(executionMaxWaitMs) => + setDraft((prev) => ({ ...prev, executionMaxWaitMs })) + } /> ) : ( @@ -349,10 +353,12 @@ function ConnectionCooldownCard({ const t = useTranslations("settings"); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); + const [prevValue, setPrevValue] = useState(value); - useEffect(() => { + if (prevValue !== value) { + setPrevValue(value); setDraft(value); - }, [value]); + } const renderProfile = (key: "oauth" | "apikey", title: string, icon: string) => { const current = editing ? draft[key] : value[key]; @@ -532,10 +538,12 @@ function ProviderBreakerCard({ const t = useTranslations("settings"); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); + const [prevValue, setPrevValue] = useState(value); - useEffect(() => { + if (prevValue !== value) { + setPrevValue(value); setDraft(value); - }, [value]); + } const renderProfile = (key: "oauth" | "apikey", title: string, icon: string) => { const current = editing ? draft[key] : value[key]; @@ -642,10 +650,12 @@ function WaitForCooldownCard({ const t = useTranslations("settings"); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); + const [prevValue, setPrevValue] = useState(value); - useEffect(() => { + if (prevValue !== value) { + setPrevValue(value); setDraft(value); - }, [value]); + } return ( @@ -738,10 +748,12 @@ function ComboCooldownWaitCard({ const t = useTranslations("settings"); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); + const [prevValue, setPrevValue] = useState(value); - useEffect(() => { + if (prevValue !== value) { + setPrevValue(value); setDraft(value); - }, [value]); + } const title = t("resilienceComboCooldownWaitTitle"); const desc = @@ -848,10 +860,12 @@ function QuotaShareConcurrencyLimitCard({ const t = useTranslations("settings"); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); + const [prevValue, setPrevValue] = useState(value); - useEffect(() => { + if (prevValue !== value) { + setPrevValue(value); setDraft(value); - }, [value]); + } const title = t("resilienceQuotaShareConcurrencyTitle"); const desc = @@ -918,10 +932,12 @@ export function ProviderCooldownCard({ const t = useTranslations("settings"); const [editing, setEditing] = useState(value); const [isEditing, setIsEditing] = useState(false); + const [prevValue, setPrevValue] = useState(value); - useEffect(() => { + if (prevValue !== value) { + setPrevValue(value); setEditing(value); - }, [value]); + } return ( @@ -1025,10 +1041,12 @@ function CredentialHealthCheckCard({ const t = useTranslations("settings"); const [editing, setEditing] = useState(value); const [isEditing, setIsEditing] = useState(false); + const [prevValue, setPrevValue] = useState(value); - useEffect(() => { + if (prevValue !== value) { + setPrevValue(value); setEditing(value); - }, [value]); + } const disabled = editing.intervalMinutes <= 0; @@ -1037,7 +1055,9 @@ function CredentialHealthCheckCard({
- health_and_safety + + health_and_safety +

{t("resilienceCredentialHealthTitle")}

{ - setLoading(true); - try { - const res = await fetch("/api/settings", { cache: "no-store" }); - if (!res.ok) return; - const data = await res.json(); - setSettings({ - fallbackStrategy: data?.fallbackStrategy || "fill-first", - stickyRoundRobinLimit: data?.stickyRoundRobinLimit ?? 3, - comboStrategy: data?.comboStrategy || "fallback", - comboStickyRoundRobinLimit: data?.comboStickyRoundRobinLimit ?? 1, - }); - } finally { - setLoading(false); - } - }, []); - useEffect(() => { - load().catch(console.error); - }, [load]); + let cancelled = false; + void (async () => { + try { + const res = await fetch("/api/settings", { cache: "no-store" }); + if (!res.ok) return; + const data = await res.json(); + if (cancelled) return; + setSettings({ + fallbackStrategy: data?.fallbackStrategy || "fill-first", + stickyRoundRobinLimit: data?.stickyRoundRobinLimit ?? 3, + comboStrategy: data?.comboStrategy || "fallback", + comboStickyRoundRobinLimit: data?.comboStickyRoundRobinLimit ?? 1, + }); + } catch (e) { + console.error(e); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); const run = useCallback(async (fn: () => Promise) => { setBusy(true); @@ -109,7 +113,10 @@ function AccountRoundRobinSection({ t, busy, settings, setSettings, run }: Secti run(async () => { const next = accountRoundRobin ? "fill-first" : "round-robin"; const updated = await patchSettings({ fallbackStrategy: next }); - setSettings((prev) => ({ ...prev, fallbackStrategy: updated.fallbackStrategy || next })); + setSettings((prev) => ({ + ...prev, + fallbackStrategy: updated.fallbackStrategy || next, + })); }) } /> @@ -128,7 +135,9 @@ function AccountRoundRobinSection({ t, busy, settings, setSettings, run }: Secti disabled={busy} className="w-16 sm:w-20 text-center shrink-0" value={settings.stickyRoundRobinLimit ?? 3} - onChange={(e) => setSettings((prev) => ({ ...prev, stickyRoundRobinLimit: e.target.value }))} + onChange={(e) => + setSettings((prev) => ({ ...prev, stickyRoundRobinLimit: e.target.value })) + } onBlur={() => run(async () => { const limit = Math.min( @@ -266,8 +275,20 @@ export default function RoutingStrategyCard() {

{tc("loading")}

) : (
- - + + { + if (!data) return; + setStorageHealth((prev) => ({ ...prev, ...data })); + setBackupCleanupOptions({ + keepLatest: data.backupRetention?.maxFiles || 20, + retentionDays: data.backupRetention?.days || 0, + }); + }, []); + const loadStorageHealth = async () => { - try { - const res = await fetch("/api/storage/health"); - if (!res.ok) return; - const data = await res.json(); - setStorageHealth((prev) => ({ ...prev, ...data })); - setBackupCleanupOptions({ - keepLatest: data.backupRetention?.maxFiles || 20, - retentionDays: data.backupRetention?.days || 0, - }); - } catch (err) { - console.error("Failed to fetch storage health:", err); - } + applyStorageHealth(await fetchStorageHealthData()); }; + const applyDatabaseSettings = useCallback((data) => { + if (data) setDbSettings(data); + setDbSettingsLoading(false); + }, []); + const loadDatabaseSettings = async () => { setDbSettingsLoading(true); - try { - const res = await fetch("/api/settings/database"); - if (res.ok) { - const data = await res.json(); - setDbSettings(data); - } - } catch (err) { - console.error("Failed to load database settings:", err); - } finally { - setDbSettingsLoading(false); - } + applyDatabaseSettings(await fetchDatabaseSettingsData()); }; const saveDatabaseSettings = async () => { @@ -480,9 +494,19 @@ export default function SystemStorageTab() { }; useEffect(() => { - loadStorageHealth(); - loadDatabaseSettings(); - }, []); + let cancelled = false; + void (async () => { + const data = await fetchStorageHealthData(); + if (!cancelled) applyStorageHealth(data); + })(); + void (async () => { + const data = await fetchDatabaseSettingsData(); + if (!cancelled) applyDatabaseSettings(data); + })(); + return () => { + cancelled = true; + }; + }, [applyStorageHealth, applyDatabaseSettings]); /** Triggers a browser file download from an existing Blob. */ const triggerDownload = (blob: Blob, filename: string) => { diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/GlobalConfigTab.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/GlobalConfigTab.tsx index 7197cccad9..982d776c71 100644 --- a/src/app/(dashboard)/dashboard/settings/components/proxy/GlobalConfigTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/GlobalConfigTab.tsx @@ -17,6 +17,17 @@ type HealthcheckSummary = { failed: number; }; +async function fetchGlobalProxyConfig(): Promise { + try { + const res = await fetch("/api/settings/proxy?level=global"); + if (res.ok) { + const data = await res.json(); + return data.proxy || null; + } + } catch {} + return undefined; +} + export default function GlobalConfigTab() { const [proxyModalOpen, setProxyModalOpen] = useState(false); const [globalProxy, setGlobalProxy] = useState(null); @@ -32,37 +43,33 @@ export default function GlobalConfigTab() { const tc = useTranslations("common"); const loadGlobalProxy = useCallback(async () => { - try { - const res = await fetch("/api/settings/proxy?level=global"); - if (res.ok) { - const data = await res.json(); - setGlobalProxy(data.proxy || null); - } - } catch {} - }, []); - - const loadPerKeyProxyEnabled = useCallback(async () => { - try { - const res = await fetch("/api/settings", { cache: "no-store" }); - if (res.ok) { - const data = await res.json(); - if (mountedRef.current) setPerKeyProxyEnabled(data.perKeyProxyEnabled === true); - } - } catch { - /* leave default */ - } finally { - if (mountedRef.current) setPerKeyLoading(false); - } + const proxy = await fetchGlobalProxyConfig(); + if (proxy !== undefined) setGlobalProxy(proxy); }, []); useEffect(() => { mountedRef.current = true; - loadGlobalProxy(); - loadPerKeyProxyEnabled(); + void (async () => { + const proxy = await fetchGlobalProxyConfig(); + if (mountedRef.current && proxy !== undefined) setGlobalProxy(proxy); + })(); + void (async () => { + try { + const res = await fetch("/api/settings", { cache: "no-store" }); + if (res.ok) { + const data = await res.json(); + if (mountedRef.current) setPerKeyProxyEnabled(data.perKeyProxyEnabled === true); + } + } catch { + /* leave default */ + } finally { + if (mountedRef.current) setPerKeyLoading(false); + } + })(); return () => { mountedRef.current = false; }; - }, [loadGlobalProxy, loadPerKeyProxyEnabled]); + }, []); const handleTogglePerKeyProxyEnabled = async () => { const newValue = !perKeyProxyEnabled; diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/SubscriptionTab.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/SubscriptionTab.tsx index 60ce022d56..bac00d4b8d 100644 --- a/src/app/(dashboard)/dashboard/settings/components/proxy/SubscriptionTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/SubscriptionTab.tsx @@ -48,6 +48,21 @@ const EMPTY_FORM: FormState = { enabled: true, }; +type SubscriptionsFetchResult = { items?: SubscriptionRecord[]; error?: string }; + +async function fetchSubscriptionsResult( + loadFailedMessage: string +): Promise { + try { + const res = await fetch("/api/v1/management/proxy-subscriptions"); + if (!res.ok) throw new Error(loadFailedMessage); + const data = await res.json(); + return { items: Array.isArray(data.items) ? data.items : [] }; + } catch (e) { + return { error: e instanceof Error ? e.message : String(e) }; + } +} + export default function SubscriptionTab() { const [subs, setSubs] = useState([]); const [providers, setProviders] = useState([]); @@ -83,43 +98,46 @@ export default function SubscriptionTab() { const [saving, setSaving] = useState(false); const [formError, setFormError] = useState(null); + const applyLoadResult = useCallback((result: SubscriptionsFetchResult) => { + if (result.items) setSubs(result.items); + if (result.error !== undefined) setError(result.error); + setLoading(false); + }, []); + const load = useCallback(async () => { setLoading(true); setError(null); - try { - const res = await fetch("/api/v1/management/proxy-subscriptions"); - if (!res.ok) throw new Error(t("proxySubscription.loadFailed")); - const data = await res.json(); - setSubs(Array.isArray(data.items) ? data.items : []); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setLoading(false); - } - }, [t]); - - const loadProviders = useCallback(async () => { - try { - const res = await fetch("/api/providers"); - if (!res.ok) return; - const data = await res.json(); - const connections = Array.isArray(data.connections) ? data.connections : []; - setProviders( - connections.map((c: Record) => ({ - id: String(c.id), - name: typeof c.name === "string" && c.name ? c.name : String(c.provider), - provider: String(c.provider), - })) - ); - } catch { - /* non-fatal: rule mode just won't offer a provider picker */ - } - }, []); + applyLoadResult(await fetchSubscriptionsResult(t("proxySubscription.loadFailed"))); + }, [applyLoadResult, t]); useEffect(() => { - load(); - loadProviders(); - }, [load, loadProviders]); + let cancelled = false; + void (async () => { + const result = await fetchSubscriptionsResult(t("proxySubscription.loadFailed")); + if (!cancelled) applyLoadResult(result); + })(); + void (async () => { + try { + const res = await fetch("/api/providers"); + if (!res.ok) return; + const data = await res.json(); + const connections = Array.isArray(data.connections) ? data.connections : []; + if (cancelled) return; + setProviders( + connections.map((c: Record) => ({ + id: String(c.id), + name: typeof c.name === "string" && c.name ? c.name : String(c.provider), + provider: String(c.provider), + })) + ); + } catch { + /* non-fatal: rule mode just won't offer a provider picker */ + } + })(); + return () => { + cancelled = true; + }; + }, [applyLoadResult, t]); const resetForm = () => { setForm(EMPTY_FORM);