chore(lint): batch 3 of #12146 — resolve the react-hooks compiler violations in dashboard/settings

Resolves the 25 react-hooks/* React Compiler violations frozen in
config/quality/eslint-suppressions.json for the dashboard/settings area
(24 set-state-in-effect, 1 immutability), plus the adjacent
react-hooks/exhaustive-deps in ProviderAccountRoutingCard, and removes
their suppression entries. No eslint-disable added anywhere; one
pre-existing eslint-disable-line (AccessTokensTab) removed.

Techniques used:

- ResilienceTab (8×): the "sync draft state from prop via useEffect"
  cards now use the documented adjust-state-during-render pattern
  (prevValue state + conditional setState in render) instead of an
  effect.
- PricingTab visibleCount reset: same render-adjustment pattern keyed
  on the filters tuple, replacing the reset effect.
- Fetch-on-mount loaders only used by the effect (IPFilterSection,
  ModelCapabilityOverridesTab, PayloadRulesTab*, RoutingStrategyCard):
  loader inlined into the effect as an async IIFE with a cancelled
  flag; every setState now happens after the first await.
- Loaders reused by handlers/intervals (AccessTokensTab, AuthzSection,
  FallbackChainsEditor, MitmProxyTab, ModelsDevSyncTab, OneproxyTab,
  PayloadRulesTab, PoliciesPanel, PricingTab,
  ProviderAccountRoutingCard, SystemStorageTab, GlobalConfigTab,
  SubscriptionTab): split into a module-level pure fetcher + a
  useCallback applier; the effect awaits the fetcher and applies after
  the await (cancellation-guarded), while handlers keep the original
  named loader (sync setState is fine there) built from the same
  fetcher/applier — no logic duplication, identical error-message and
  loading semantics.
- OneproxyTab keeps the spinner-on-filter-change behavior via the same
  render-adjustment pattern (filtersKey → setLoading(true)).
- AccessTokensTab: the L() fallback helper is now memoized with
  useCallback([t]), which also let the old
  eslint-disable-line react-hooks/exhaustive-deps be removed.
- ProviderAccountRoutingCard: save's dependency array now includes
  load (the frozen exhaustive-deps violation).

Suppressions: all react-hooks/* entries for the 17 batch files removed
(19 rule entries, 26 violation counts). Entries for other rules/files
untouched.

Refs #12146
This commit is contained in:
diegosouzapw
2026-08-30 20:29:48 -03:00
parent 8b7afc0eba
commit 91de2d9406
18 changed files with 669 additions and 423 deletions

View File

@@ -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": {

View File

@@ -21,11 +21,21 @@ const SCOPE_VARIANT: Record<string, "info" | "warning" | "error" | "default"> =
admin: "error",
};
async function fetchTokens(): Promise<AccessTokenRow[]> {
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<AccessTokenRow[]>([]);
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)}
/>
<Button onClick={createToken} disabled={creating || !name.trim()}>
{creating ? L("accessTokensCreating", "Creating…") : L("accessTokensCreate", "Create")}
{creating
? L("accessTokensCreating", "Creating…")
: L("accessTokensCreate", "Create")}
</Button>
</div>
@@ -213,10 +237,18 @@ export default function AccessTokensTab() {
<tr className="border-b border-border text-left text-text-muted">
<th className="py-2 pr-4 font-medium">{L("accessTokensColName", "Name")}</th>
<th className="py-2 pr-4 font-medium">{L("accessTokensColScope", "Scope")}</th>
<th className="py-2 pr-4 font-medium">{L("accessTokensColPrefix", "Prefix")}</th>
<th className="py-2 pr-4 font-medium">{L("accessTokensColStatus", "Status")}</th>
<th className="py-2 pr-4 font-medium">{L("accessTokensColLastUsed", "Last used")}</th>
<th className="py-2 pr-4 font-medium">{L("accessTokensColExpires", "Expires")}</th>
<th className="py-2 pr-4 font-medium">
{L("accessTokensColPrefix", "Prefix")}
</th>
<th className="py-2 pr-4 font-medium">
{L("accessTokensColStatus", "Status")}
</th>
<th className="py-2 pr-4 font-medium">
{L("accessTokensColLastUsed", "Last used")}
</th>
<th className="py-2 pr-4 font-medium">
{L("accessTokensColExpires", "Expires")}
</th>
<th className="py-2 font-medium" />
</tr>
</thead>

View File

@@ -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<InventoryFetchResult> {
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<InventoryPayload | null>(null);
@@ -110,32 +129,38 @@ export default function AuthzSection() {
const [submitting, setSubmitting] = useState(false);
const [status, setStatus] = useState<StatusMessage | null>(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;

View File

@@ -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()) {

View File

@@ -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", {

View File

@@ -63,6 +63,19 @@ function formatDate(value: string | null) {
}
}
type MitmStatusResult = { data: MitmStatus } | { errorMessage: string | null };
async function fetchMitmStatusResult(): Promise<MitmStatusResult> {
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<MitmStatus>(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<string, unknown>, successMessage: string) => {
setSaving(true);

View File

@@ -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<string, PricingCatalogProvider>);
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 };
}

View File

@@ -64,6 +64,16 @@ function formatInterval(hours: number): string {
return hours === 168 ? "7d" : `${hours}h`;
}
async function fetchSyncStatusData(): Promise<ModelsDevStatus | null> {
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<ModelsDevStatus | null>(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);

View File

@@ -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<OneproxyFetchResult> {
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<OneproxyItem[]>([]);
@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -67,6 +67,37 @@ function getSourceTone(source: PricingSource): string {
}
}
type PricingBundle = {
catalog?: Record<string, PricingCatalogProvider>;
pricing?: Record<string, Record<string, Record<string, number>>>;
sources?: Record<string, Record<string, PricingSource>>;
sync?: SyncStatus;
};
async function fetchPricingBundle(): Promise<PricingBundle> {
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<string, PricingCatalogProvider>;
}
if (pricingRes.ok) {
const pricingPayload = (await pricingRes.json()) as {
pricing?: Record<string, Record<string, Record<string, number>>>;
sourceMap?: Record<string, Record<string, PricingSource>>;
};
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<Record<string, PricingCatalogProvider>>({});
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<string, PricingCatalogProvider>);
}
if (pricingRes.ok) {
const pricingPayload = (await pricingRes.json()) as {
pricing?: Record<string, Record<string, Record<string, number>>>;
sourceMap?: Record<string, Record<string, PricingSource>>;
};
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);

View File

@@ -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<string, ProviderStrategyOverride>)[
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<string, ProviderStrategyOverride>)[
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
</div>
</div>
);
}
}

View File

@@ -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 (
<Card className="p-6">
@@ -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 (
<Card className="p-6">
@@ -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 (
<Card className="p-6">
@@ -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({
<div className="mb-4 flex items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary">health_and_safety</span>
<span className="material-symbols-outlined text-xl text-primary">
health_and_safety
</span>
<h2 className="text-lg font-bold">{t("resilienceCredentialHealthTitle")}</h2>
</div>
<SectionDescription

View File

@@ -50,26 +50,30 @@ function useRoutingStrategySettings() {
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
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<void>) => {
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() {
<p className="text-sm text-text-muted">{tc("loading")}</p>
) : (
<div className="flex flex-col gap-4">
<AccountRoundRobinSection t={t} busy={busy} settings={settings} setSettings={setSettings} run={run} />
<ComboRoundRobinSection t={t} busy={busy} settings={settings} setSettings={setSettings} run={run} />
<AccountRoundRobinSection
t={t}
busy={busy}
settings={settings}
setSettings={setSettings}
run={run}
/>
<ComboRoundRobinSection
t={t}
busy={busy}
settings={settings}
setSettings={setSettings}
run={run}
/>
<RoutingSummaryFooter
t={t}
accountRoundRobin={accountRoundRobin}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { Card, Button, Badge, ConfirmModal } from "@/shared/components";
import { useLocale, useTranslations } from "next-intl";
import DatabaseBackupRetentionCard from "./DatabaseBackupRetentionCard";
@@ -18,6 +18,27 @@ const RESET_USAGE_PERIOD_VALUES = [
"all",
] as const;
async function fetchStorageHealthData() {
try {
const res = await fetch("/api/storage/health");
if (!res.ok) return null;
return await res.json();
} catch (err) {
console.error("Failed to fetch storage health:", err);
return null;
}
}
async function fetchDatabaseSettingsData() {
try {
const res = await fetch("/api/settings/database");
if (res.ok) return await res.json();
} catch (err) {
console.error("Failed to load database settings:", err);
}
return null;
}
export default function SystemStorageTab() {
const [backups, setBackups] = useState([]);
const [backupsLoading, setBackupsLoading] = useState(false);
@@ -103,34 +124,27 @@ export default function SystemStorageTab() {
}
};
const applyStorageHealth = useCallback((data) => {
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) => {

View File

@@ -17,6 +17,17 @@ type HealthcheckSummary = {
failed: number;
};
async function fetchGlobalProxyConfig(): Promise<GlobalProxyConfig | undefined> {
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<GlobalProxyConfig>(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;

View File

@@ -48,6 +48,21 @@ const EMPTY_FORM: FormState = {
enabled: true,
};
type SubscriptionsFetchResult = { items?: SubscriptionRecord[]; error?: string };
async function fetchSubscriptionsResult(
loadFailedMessage: string
): Promise<SubscriptionsFetchResult> {
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<SubscriptionRecord[]>([]);
const [providers, setProviders] = useState<ProviderOption[]>([]);
@@ -83,43 +98,46 @@ export default function SubscriptionTab() {
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState<string | null>(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<string, unknown>) => ({
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<string, unknown>) => ({
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);