mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 22:32:22 +03:00
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:
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user