mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 21:32:20 +03:00
chore(lint): batch 2 of #12146 — resolve the react-hooks compiler violations in dashboard/providers
Resolves all 28 react-hooks/* compiler violations (24 set-state-in-effect, 4 refs) across the 18 dashboard/providers files of batch 2 and removes their suppression entries — no eslint-disable, no new suppressions. Techniques per file: - Fetch-on-mount loaders (CustomModelsSection, ProviderCcAliasSection, ProviderInterceptionSection, ProviderParamFilterSection, page.tsx, useProviderConnections, useProviderSettings, CliproxyAccountHealthCard, DarioAccountPanel, NinerouterModelList): network/parse/error concerns extracted to module-level helpers returning error-as-value; the async glue is defined INSIDE each effect with every setState after the await. Loaders that handlers still need (refresh/retry buttons, exposed hook API) remain as callbacks; spinner flags moved into the button handlers. - Loading flags for provider-keyed sections derived from a loadedProviderId marker instead of synchronous setLoading(true) resets. - Modal init/reset effects (EditConnectionModal, EditCompatibleNodeModal, AddCompatibleProviderModal, VolcengineConnectModal state reset, useProviderUrlFilters hydration, page.tsx display-mode fallback, useProviderSettings per-provider flag reset): converted to render-phase adjustments guarded by the previously-seen prop/marker (react.dev "adjusting state when a prop changes"). - VolcengineConnectModal: phone prefill via localStorage lazy initializer; server-side session cancel + poll stop moved to the cleanup of an open-scoped effect reading a session ref mirror. - ModelCompatPopover refs: render-time ref mirrors removed — headerRowsRef is maintained by an applyHeaderRows writer used by all handlers, paramTargetRef is mirrored in an effect, and blockText/allowText mirrors were already kept in sync by their single writer (applyParamFields). - ModelCompatPopover state: header-row loading and value-visibility resets moved from [open, protocol] effects into the open/protocol/outside-click gesture handlers; the closed-popover rect reset was dropped (render is gated on open and the rect is recomputed pre-paint on reopen). - useRiskAcknowledged: localStorage mirrored via useSyncExternalStore with a module-level listener set notified by acknowledgeProviderRisk. - useProviderModels: loading for the empty-providerId case derived at the return site instead of a synchronous setLoading in the effect. Validation: scoped eslint with the suppressions file passes with 0 problems; check-dashboard-typecheck.mjs OK; node --test batch (14 files) and vitest batch (9 files, 47 tests) green. Refs #12146
This commit is contained in:
@@ -1298,64 +1298,21 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx": {
|
||||
"react-hooks/refs": {
|
||||
"count": 4
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderCcAliasSection.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderInterceptionSection.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderParamFilterSection.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/VolcengineConnectModal.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/CursorAgentNudge.test.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/ImportCodexAuthModal.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -1374,57 +1331,11 @@
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/hooks/useProviderModels.ts": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/hooks/useProviderUrlFilters.ts": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/hooks/useRiskAcknowledged.ts": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/page.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 4
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/services/components/CliproxyAccountHealthCard.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/services/components/DarioAccountPanel.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/services/components/NinerouterModelList.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/utils/buildCurl.ts": {
|
||||
|
||||
@@ -91,6 +91,25 @@ function parseContextWindowOverrideInput(raw: string): { value: number | null; i
|
||||
return { value: Number(trimmed), invalid: false };
|
||||
}
|
||||
|
||||
// Fetch + parse extracted from the component so errors surface as a return
|
||||
// value (logged here) instead of state writes inside catch/finally blocks —
|
||||
// the load callback then only sets state after the await, which lets the
|
||||
// mount effect call it without a synchronous setState.
|
||||
async function fetchProviderModelsPayload(providerId: string): Promise<{
|
||||
models: CompatModelRow[];
|
||||
overrides: Array<CompatModelRow & { id: string }>;
|
||||
} | null> {
|
||||
try {
|
||||
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return { models: data.models || [], overrides: data.modelCompatOverrides || [] };
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch custom models:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -141,23 +160,28 @@ export default function CustomModelsSection({
|
||||
const syncedModelIdSet = useMemo(() => new Set(syncedModelIds), [syncedModelIds]);
|
||||
|
||||
const fetchCustomModels = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCustomModels(data.models || []);
|
||||
setModelCompatOverrides(data.modelCompatOverrides || []);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch custom models:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const payload = await fetchProviderModelsPayload(providerId);
|
||||
if (payload) {
|
||||
setCustomModels(payload.models);
|
||||
setModelCompatOverrides(payload.overrides);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [providerId]);
|
||||
|
||||
// Initial load: the async work is defined INSIDE the effect (calling the
|
||||
// component-scope fetchCustomModels callback synchronously from an effect is
|
||||
// rejected by the compiler rules); every setState here runs after the await.
|
||||
useEffect(() => {
|
||||
fetchCustomModels();
|
||||
}, [fetchCustomModels]);
|
||||
const run = async () => {
|
||||
const payload = await fetchProviderModelsPayload(providerId);
|
||||
if (payload) {
|
||||
setCustomModels(payload.models);
|
||||
setModelCompatOverrides(payload.overrides);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
void run();
|
||||
}, [providerId]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newModelId.trim() || adding) return;
|
||||
@@ -540,8 +564,8 @@ export default function CustomModelsSection({
|
||||
FREE
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{loading ? (
|
||||
|
||||
@@ -168,8 +168,10 @@ export default function ModelCompatPopover({
|
||||
width: number;
|
||||
} | null>(null);
|
||||
const headerRowIdRef = useRef(0);
|
||||
// Mirror of headerRows, kept in sync by applyHeaderRows below (every state
|
||||
// write goes through it), so blur/close commits read the freshest rows
|
||||
// without touching the ref during render.
|
||||
const headerRowsRef = useRef<HeaderDraftRow[]>([]);
|
||||
headerRowsRef.current = headerRows;
|
||||
|
||||
// Param-filter drafts are mirrored into a ref so the close/unmount save path reads the
|
||||
// latest typed values instead of the values captured when the handler was created (#8910).
|
||||
@@ -190,13 +192,18 @@ export default function ModelCompatPopover({
|
||||
providerId,
|
||||
modelId,
|
||||
});
|
||||
paramTargetRef.current = { key: paramTargetKey, providerId, modelId };
|
||||
// Mirrored in an effect (never during render): the ref is only read from
|
||||
// event handlers and the save path, which run after this effect committed.
|
||||
useEffect(() => {
|
||||
paramTargetRef.current = { key: paramTargetKey, providerId, modelId };
|
||||
}, [paramTargetKey, providerId, modelId]);
|
||||
|
||||
// Mirrors of the displayed text, so an edit can snapshot both fields synchronously.
|
||||
// Mirrors of the displayed text, so an edit can snapshot both fields
|
||||
// synchronously. applyParamFields below is the ONLY writer of
|
||||
// blockText/allowText and keeps these refs in sync itself, so no render-time
|
||||
// mirroring is needed (and none is allowed by the compiler's refs rule).
|
||||
const blockTextRef = useRef("");
|
||||
const allowTextRef = useRef("");
|
||||
blockTextRef.current = blockText;
|
||||
allowTextRef.current = allowText;
|
||||
// Which target the values currently in the fields belong to. Guards the invariant that
|
||||
// blockTextRef/allowTextRef never hold content belonging to a target other than the one being
|
||||
// displayed — the desync that let one model's server values be saved under another (#8910).
|
||||
@@ -273,14 +280,38 @@ export default function ModelCompatPopover({
|
||||
};
|
||||
}, [open, tryCommitHeaderRows]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const rec = getUpstreamHeadersRecord(protocol);
|
||||
setHeaderRows(recordToHeaderRows(rec, genHeaderRowId));
|
||||
// Only re-load rows when opening or switching protocol — not when the parent passes a new
|
||||
// inline callback every render (would wipe in-progress edits).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- see above
|
||||
}, [open, protocol]);
|
||||
// Rows (re)load from the parent when the popover opens or the protocol
|
||||
// switches — both user gestures — so the load lives in those handlers
|
||||
// (handleToggleOpen / handleProtocolChange) instead of an effect. This keeps
|
||||
// the old guarantee: a new inline parent callback on a re-render never wipes
|
||||
// in-progress edits.
|
||||
const applyHeaderRows = (rows: HeaderDraftRow[]) => {
|
||||
headerRowsRef.current = rows;
|
||||
setHeaderRows(rows);
|
||||
};
|
||||
|
||||
const loadHeaderRowsFor = (nextProtocol: string) => {
|
||||
const rec = getUpstreamHeadersRecord(nextProtocol);
|
||||
applyHeaderRows(recordToHeaderRows(rec, genHeaderRowId));
|
||||
};
|
||||
|
||||
const resetValueVisibility = () => {
|
||||
setValuePeekRowId(null);
|
||||
setValueFocusRowId(null);
|
||||
};
|
||||
|
||||
const handleToggleOpen = () => {
|
||||
const next = !open;
|
||||
setOpen(next);
|
||||
resetValueVisibility();
|
||||
if (next) loadHeaderRowsFor(protocol);
|
||||
};
|
||||
|
||||
const handleProtocolChange = (nextProtocol: string) => {
|
||||
setProtocol(nextProtocol);
|
||||
resetValueVisibility();
|
||||
if (open) loadHeaderRowsFor(nextProtocol);
|
||||
};
|
||||
|
||||
// Load model-level block/allow from param-filters API
|
||||
useEffect(() => {
|
||||
@@ -420,30 +451,23 @@ export default function ModelCompatPopover({
|
||||
};
|
||||
}, [open, paramTargetKey, saveModelParamFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
setValuePeekRowId(null);
|
||||
setValueFocusRowId(null);
|
||||
}, [open, protocol]);
|
||||
|
||||
const namedHeaderCount = headerRows.filter((r) => r.name.trim()).length;
|
||||
const canAddHeaderRow = namedHeaderCount < UPSTREAM_HEADERS_UI_MAX;
|
||||
|
||||
const updateHeaderRow = (id: string, patch: Partial<Pick<HeaderDraftRow, "name" | "value">>) => {
|
||||
setHeaderRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
applyHeaderRows(headerRowsRef.current.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
};
|
||||
|
||||
const addHeaderRow = () => {
|
||||
if (!canAddHeaderRow) return;
|
||||
setHeaderRows((prev) => [...prev, { id: genHeaderRowId(), name: "", value: "" }]);
|
||||
applyHeaderRows([...headerRowsRef.current, { id: genHeaderRowId(), name: "", value: "" }]);
|
||||
};
|
||||
|
||||
const removeHeaderRow = (id: string) => {
|
||||
setHeaderRows((prev) => {
|
||||
const next = prev.filter((r) => r.id !== id);
|
||||
const normalized = next.length === 0 ? [{ id: genHeaderRowId(), name: "", value: "" }] : next;
|
||||
queueMicrotask(() => tryCommitHeaderRows(normalized));
|
||||
return normalized;
|
||||
});
|
||||
const next = headerRowsRef.current.filter((r) => r.id !== id);
|
||||
const normalized = next.length === 0 ? [{ id: genHeaderRowId(), name: "", value: "" }] : next;
|
||||
applyHeaderRows(normalized);
|
||||
queueMicrotask(() => tryCommitHeaderRows(normalized));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -452,7 +476,11 @@ export default function ModelCompatPopover({
|
||||
const target = e.target as Node;
|
||||
const insideTrigger = ref.current?.contains(target);
|
||||
const insidePanel = panelRef.current?.contains(target);
|
||||
if (!insideTrigger && !insidePanel) setOpen(false);
|
||||
if (!insideTrigger && !insidePanel) {
|
||||
setOpen(false);
|
||||
setValuePeekRowId(null);
|
||||
setValueFocusRowId(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
return () => document.removeEventListener("mousedown", onDocClick);
|
||||
@@ -478,10 +506,10 @@ export default function ModelCompatPopover({
|
||||
}, [open]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
setPortalPanelRect(null);
|
||||
return;
|
||||
}
|
||||
// No rect reset on close: the portal render is gated on `open`, and
|
||||
// reopening recomputes the rect below before the browser paints, so a
|
||||
// stale rect is never visible.
|
||||
if (!open) return;
|
||||
updatePortalPanelRect();
|
||||
window.addEventListener("resize", updatePortalPanelRect);
|
||||
window.addEventListener("scroll", updatePortalPanelRect, true);
|
||||
@@ -498,7 +526,7 @@ export default function ModelCompatPopover({
|
||||
<div className="relative inline-flex" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onClick={handleToggleOpen}
|
||||
disabled={disabled}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg border border-border bg-background text-text-muted hover:bg-muted hover:text-text-main disabled:opacity-50 transition-colors"
|
||||
title={t("compatAdjustmentsTitle")}
|
||||
@@ -535,7 +563,7 @@ export default function ModelCompatPopover({
|
||||
</label>
|
||||
<select
|
||||
value={protocol}
|
||||
onChange={(e) => setProtocol(e.target.value)}
|
||||
onChange={(e) => handleProtocolChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="mb-4 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-2 text-xs text-text-main focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
|
||||
>
|
||||
|
||||
@@ -77,30 +77,46 @@ function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
// Wraps the GET so a failure comes back as a value: the load callback then only
|
||||
// sets state after the await (no synchronous setState reachable from the effect).
|
||||
async function fetchCcAliasStateSafe(
|
||||
providerId: string
|
||||
): Promise<{ ok: boolean; state?: CcAliasState; error?: string }> {
|
||||
try {
|
||||
return { ok: true, state: await fetchCcAliasState(providerId) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: errorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads the provider's alias settings once and reports a load failure to the operator. */
|
||||
function useCcAliasData(providerId: string, t: ProviderMessageTranslator) {
|
||||
const notify = useNotificationStore();
|
||||
const [state, setState] = useState<CcAliasState>(DEFAULT_STATE);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadState = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setState(await fetchCcAliasState(providerId));
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
providerText(t, "ccAliasLoadError", "Failed to load discovery-alias settings: {error}", {
|
||||
error: errorMessage(err),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [providerId, notify, t]);
|
||||
// Loading is derived: true until a load attempt for the CURRENT provider
|
||||
// settles — this also re-shows the skeleton when providerId changes.
|
||||
const [loadedProviderId, setLoadedProviderId] = useState<string | null>(null);
|
||||
const loading = loadedProviderId !== providerId;
|
||||
|
||||
// The async work is defined INSIDE the effect (a component-scope loader
|
||||
// called synchronously from an effect is rejected by the compiler rules);
|
||||
// every setState here runs after the await.
|
||||
useEffect(() => {
|
||||
loadState();
|
||||
}, [loadState]);
|
||||
const run = async () => {
|
||||
const outcome = await fetchCcAliasStateSafe(providerId);
|
||||
if (outcome.ok) {
|
||||
setState(outcome.state);
|
||||
} else {
|
||||
notify.error(
|
||||
providerText(t, "ccAliasLoadError", "Failed to load discovery-alias settings: {error}", {
|
||||
error: outcome.error,
|
||||
})
|
||||
);
|
||||
}
|
||||
setLoadedProviderId(providerId);
|
||||
};
|
||||
void run();
|
||||
}, [providerId, notify, t]);
|
||||
|
||||
return { state, setState, loading };
|
||||
}
|
||||
|
||||
@@ -63,26 +63,42 @@ function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
// Wraps the GET so a failure comes back as a value: the load callback then only
|
||||
// sets state after the await (no synchronous setState reachable from the effect).
|
||||
async function fetchInterceptionTogglesSafe(
|
||||
providerId: string
|
||||
): Promise<{ ok: boolean; toggles?: InterceptionToggles; error?: string }> {
|
||||
try {
|
||||
return { ok: true, toggles: await fetchInterceptionToggles(providerId) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: errorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
function useProviderInterceptionToggles(providerId: string, t: Translate) {
|
||||
const notify = useNotificationStore();
|
||||
const [toggles, setToggles] = useState<InterceptionToggles>(DEFAULT_TOGGLES);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Loading is derived: true until a load attempt for the CURRENT provider
|
||||
// settles — this also re-shows the skeleton when providerId changes.
|
||||
const [loadedProviderId, setLoadedProviderId] = useState<string | null>(null);
|
||||
const loading = loadedProviderId !== providerId;
|
||||
const [savingKey, setSavingKey] = useState<keyof InterceptionToggles | null>(null);
|
||||
|
||||
const loadToggles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setToggles(await fetchInterceptionToggles(providerId));
|
||||
} catch (err) {
|
||||
notify.error(t("interceptionLoadError", { error: errorMessage(err) }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [providerId, notify, t]);
|
||||
|
||||
// The async work is defined INSIDE the effect (a component-scope loader
|
||||
// called synchronously from an effect is rejected by the compiler rules);
|
||||
// every setState here runs after the await.
|
||||
useEffect(() => {
|
||||
loadToggles();
|
||||
}, [loadToggles]);
|
||||
const run = async () => {
|
||||
const outcome = await fetchInterceptionTogglesSafe(providerId);
|
||||
if (outcome.ok) {
|
||||
setToggles(outcome.toggles);
|
||||
} else {
|
||||
notify.error(t("interceptionLoadError", { error: outcome.error }));
|
||||
}
|
||||
setLoadedProviderId(providerId);
|
||||
};
|
||||
void run();
|
||||
}, [providerId, notify, t]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (key: keyof InterceptionToggles, value: boolean) => {
|
||||
@@ -130,9 +146,7 @@ export default function ProviderInterceptionSection({
|
||||
<h2 className="text-base font-semibold text-text-main mb-1">
|
||||
{t("interceptionSectionTitle")}
|
||||
</h2>
|
||||
<p className="text-xs text-text-muted mb-4 leading-relaxed">
|
||||
{t("interceptionSectionHint")}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mb-4 leading-relaxed">{t("interceptionSectionHint")}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Toggle
|
||||
size="sm"
|
||||
|
||||
@@ -74,6 +74,18 @@ function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
// Wraps the GET so a failure comes back as a value: the load callback then only
|
||||
// sets state after the await (no synchronous setState reachable from the effect).
|
||||
async function fetchParamFilterConfigSafe(
|
||||
providerId: string
|
||||
): Promise<{ ok: boolean; config?: ParamFilterConfig; error?: string }> {
|
||||
try {
|
||||
return { ok: true, config: await fetchParamFilterConfig(providerId) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: errorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State hook — owns config load/save/reset so the component body stays JSX-only.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -95,7 +107,10 @@ function useDirtySetter<T>(setValue: (value: T) => void, setDirty: (value: boole
|
||||
function useProviderParamFilterConfig(providerId: string, t: Translate) {
|
||||
const notify = useNotificationStore();
|
||||
const [, setConfig] = useState<ParamFilterConfig>({ block: [], allow: [], autoLearn: false });
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Loading is derived: true until a load attempt for the CURRENT provider
|
||||
// settles — this also re-shows the skeleton when providerId changes.
|
||||
const [loadedProviderId, setLoadedProviderId] = useState<string | null>(null);
|
||||
const loading = loadedProviderId !== providerId;
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [blockText, setBlockTextState] = useState("");
|
||||
@@ -106,24 +121,24 @@ function useProviderParamFilterConfig(providerId: string, t: Translate) {
|
||||
const setAllowText = useDirtySetter(setAllowTextState, setDirty);
|
||||
const setAutoLearn = useDirtySetter(setAutoLearnState, setDirty);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const cfg = await fetchParamFilterConfig(providerId);
|
||||
setConfig(cfg);
|
||||
setBlockTextState(formatCommaList(cfg.block));
|
||||
setAllowTextState(formatCommaList(cfg.allow));
|
||||
setAutoLearnState(cfg.autoLearn);
|
||||
} catch (err) {
|
||||
notify.notify(t("paramFiltersLoadError", { error: errorMessage(err) }), "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [providerId, notify, t]);
|
||||
|
||||
// The async work is defined INSIDE the effect (a component-scope loader
|
||||
// called synchronously from an effect is rejected by the compiler rules);
|
||||
// every setState here runs after the await.
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
const run = async () => {
|
||||
const outcome = await fetchParamFilterConfigSafe(providerId);
|
||||
if (outcome.ok) {
|
||||
setConfig(outcome.config);
|
||||
setBlockTextState(formatCommaList(outcome.config.block));
|
||||
setAllowTextState(formatCommaList(outcome.config.allow));
|
||||
setAutoLearnState(outcome.config.autoLearn);
|
||||
} else {
|
||||
notify.notify(t("paramFiltersLoadError", { error: outcome.error }), "error");
|
||||
}
|
||||
setLoadedProviderId(providerId);
|
||||
};
|
||||
void run();
|
||||
}, [providerId, notify, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
|
||||
@@ -88,7 +88,16 @@ export default function VolcengineConnectModal({
|
||||
notify,
|
||||
t,
|
||||
}: VolcengineConnectModalProps) {
|
||||
const [phone, setPhone] = useState("");
|
||||
// Prefilled from the last successful login via a lazy initializer — reading
|
||||
// localStorage inside the open effect required a synchronous setState there.
|
||||
const [phone, setPhone] = useState(() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
try {
|
||||
return localStorage.getItem(PHONE_STORAGE_KEY) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
const [code, setCode] = useState("");
|
||||
const [captcha, setCaptcha] = useState("");
|
||||
const [session, setSession] = useState<SessionView | null>(null);
|
||||
@@ -99,6 +108,12 @@ export default function VolcengineConnectModal({
|
||||
const [resendCountdown, setResendCountdown] = useState(0);
|
||||
|
||||
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// Latest session, mirrored for the close/unmount cleanup below — that effect
|
||||
// only depends on isOpen, so reading the state directly would be stale.
|
||||
const sessionRef = useRef<SessionView | null>(null);
|
||||
useEffect(() => {
|
||||
sessionRef.current = session;
|
||||
}, [session]);
|
||||
|
||||
// ── lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -117,22 +132,35 @@ export default function VolcengineConnectModal({
|
||||
setResendCountdown(0);
|
||||
}, [stopTimers]);
|
||||
|
||||
// Leaving the modal cancels an in-flight session server-side and stops
|
||||
// polling. Runs as the cleanup of this open-scoped effect (no setState here).
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Leaving the modal cancels an in-flight session server-side.
|
||||
const active = session && !isTerminal(session.phase) ? session : null;
|
||||
if (!isOpen) return;
|
||||
return () => {
|
||||
const current = sessionRef.current;
|
||||
const active = current && !isTerminal(current.phase) ? current : null;
|
||||
if (active) {
|
||||
void fetch(`/api/providers/volcengine-plan/connect/${active.sessionId}/cancel`, {
|
||||
method: "POST",
|
||||
}).catch(() => {});
|
||||
}
|
||||
reset();
|
||||
return;
|
||||
stopTimers();
|
||||
};
|
||||
}, [isOpen, stopTimers]);
|
||||
|
||||
// Local state reset on close — a render-phase adjustment guarded by the
|
||||
// previous isOpen value (react.dev "adjusting state when a prop changes")
|
||||
// instead of a synchronous setState inside an effect.
|
||||
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
|
||||
if (isOpen !== prevIsOpen) {
|
||||
setPrevIsOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
setSession(null);
|
||||
setCode("");
|
||||
setCaptcha("");
|
||||
setResendCountdown(0);
|
||||
}
|
||||
const saved = typeof window !== "undefined" ? localStorage.getItem(PHONE_STORAGE_KEY) : null;
|
||||
if (saved) setPhone(saved);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
}
|
||||
|
||||
useEffect(() => stopTimers, [stopTimers]);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Badge, Input, Modal, Select, Toggle } from "@/shared/components";
|
||||
import { isValidProviderIconUrl } from "@/shared/validation/iconUrl";
|
||||
@@ -62,8 +62,23 @@ export default function EditCompatibleNodeModal({
|
||||
const [iconUrlError, setIconUrlError] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && node) {
|
||||
// Modal-open form initialization from the node being edited — applied as a
|
||||
// render-phase adjustment guarded by the previously initialized node
|
||||
// (react.dev "adjusting state when a prop changes") instead of a
|
||||
// synchronous-setState effect. Closing clears the marker so the next open
|
||||
// re-initializes again.
|
||||
const [initializedFor, setInitializedFor] = useState<{
|
||||
node: EditCompatibleNodeModalNode;
|
||||
isAnthropic?: boolean;
|
||||
isCcCompatible?: boolean;
|
||||
} | null>(null);
|
||||
if (isOpen && node) {
|
||||
if (
|
||||
initializedFor?.node !== node ||
|
||||
initializedFor.isAnthropic !== isAnthropic ||
|
||||
initializedFor.isCcCompatible !== isCcCompatible
|
||||
) {
|
||||
setInitializedFor({ node, isAnthropic, isCcCompatible });
|
||||
const psd = (node.providerSpecificData || {}) as Record<string, unknown>;
|
||||
setFormData({
|
||||
name: node.name || "",
|
||||
@@ -94,7 +109,9 @@ export default function EditCompatibleNodeModal({
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [isOpen, node, isAnthropic, isCcCompatible]);
|
||||
} else if (initializedFor !== null) {
|
||||
setInitializedFor(null);
|
||||
}
|
||||
|
||||
const apiTypeOptions = [
|
||||
{ value: "chat", label: t("chatCompletions") },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Badge, Input, Modal, Toggle, Select } from "@/shared/components";
|
||||
import {
|
||||
@@ -262,8 +262,19 @@ export default function EditConnectionModal({
|
||||
: apiKeyOptional
|
||||
? t("apiKeyOptionalHint")
|
||||
: t("leaveBlankKeepCurrentApiKey");
|
||||
useEffect(() => {
|
||||
if (isOpen && connection) {
|
||||
// Modal-open form initialization from the loaded connection — applied as a
|
||||
// render-phase adjustment guarded by the previously initialized connection
|
||||
// (react.dev "adjusting state when a prop changes") instead of the former
|
||||
// synchronous-setState effect. Remounting the 30+ field form per connection
|
||||
// id stays out of scope (#11251 follow-up, #9985); closing clears the marker
|
||||
// so the next open re-initializes again.
|
||||
const [initializedFor, setInitializedFor] = useState<{
|
||||
connection: EditConnectionModalConnection;
|
||||
providerId: string;
|
||||
} | null>(null);
|
||||
if (isOpen && connection) {
|
||||
if (initializedFor?.connection !== connection || initializedFor.providerId !== providerId) {
|
||||
setInitializedFor({ connection, providerId });
|
||||
const effectiveProvider = connection.provider || providerId;
|
||||
const existingBaseUrl = stringField(connection.providerSpecificData?.baseUrl);
|
||||
const existingTargetFormat = stringField(connection.providerSpecificData?.targetFormat);
|
||||
@@ -293,13 +304,6 @@ export default function EditConnectionModal({
|
||||
connection.providerSpecificData?.quotaPerUnit != null
|
||||
? String(connection.providerSpecificData.quotaPerUnit)
|
||||
: "";
|
||||
// Modal-open form initialization from the loaded connection (sync with an
|
||||
// external system on `isOpen`); remounting the 30+ field form per
|
||||
// connection id is a behavior-risking restructure out of scope here
|
||||
// (#11251 follow-up, #9985).
|
||||
// NOTE: no react-hooks/set-state-in-effect suppression needed — the rule
|
||||
// only fires on unconditional synchronous setState, and this one is
|
||||
// guarded by the isOpen/connection condition above.
|
||||
setFormData({
|
||||
name: connection.name || "",
|
||||
priority: connection.priority || 1,
|
||||
@@ -437,15 +441,9 @@ export default function EditConnectionModal({
|
||||
setValidatedProviderSpecificData(undefined);
|
||||
setSaveError(null);
|
||||
}
|
||||
}, [
|
||||
isOpen,
|
||||
connection,
|
||||
providerId,
|
||||
defaultBaseUrl,
|
||||
showsRegion,
|
||||
defaultRegion,
|
||||
setOpenRouterPreset,
|
||||
]);
|
||||
} else if (initializedFor !== null) {
|
||||
setInitializedFor(null);
|
||||
}
|
||||
const handleTest = async () => {
|
||||
if (!provider) return;
|
||||
setTesting(true);
|
||||
|
||||
@@ -42,6 +42,95 @@ import {
|
||||
const MAX_BULK_IDS = 100;
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
// ──── module-level fetch helpers ────────────────────────────────────────────
|
||||
// The network/parse/retry concerns live outside the hook so the callbacks
|
||||
// below only set state after the await — the mount effect can then call them
|
||||
// without a synchronous setState (errors come back as values, not as state
|
||||
// writes inside catch/finally blocks).
|
||||
|
||||
interface ProviderConnectionsFetchResult {
|
||||
connections: ConnectionRowConnection[] | null;
|
||||
node: any;
|
||||
nodeResolved: boolean;
|
||||
}
|
||||
|
||||
async function loadProviderConnectionsData(
|
||||
providerId: string,
|
||||
isCompatible: boolean
|
||||
): Promise<ProviderConnectionsFetchResult | null> {
|
||||
try {
|
||||
const connectionsUrl = getProviderConnectionsRequestUrl(providerId);
|
||||
const [connectionsRes, nodesRes] = await Promise.all([
|
||||
fetch(connectionsUrl, { cache: "no-store" }),
|
||||
fetch("/api/provider-nodes", { cache: "no-store" }),
|
||||
]);
|
||||
const connectionsData = await connectionsRes.json();
|
||||
const nodesData = await nodesRes.json();
|
||||
const connections = connectionsRes.ok
|
||||
? (connectionsData.connections || []).filter((c: any) =>
|
||||
connectionBelongsToProviderPage(c.provider, providerId)
|
||||
)
|
||||
: null;
|
||||
let node = null;
|
||||
let nodeResolved = false;
|
||||
if (nodesRes.ok) {
|
||||
nodeResolved = true;
|
||||
node = (nodesData.nodes || []).find((entry: any) => entry.id === providerId) || null;
|
||||
|
||||
// Newly created compatible nodes can be briefly unavailable on one worker.
|
||||
if (!node && isCompatible) {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
const retryRes = await fetch("/api/provider-nodes", { cache: "no-store" });
|
||||
if (!retryRes.ok) continue;
|
||||
const retryData = await retryRes.json();
|
||||
node = (retryData.nodes || []).find((entry: any) => entry.id === providerId) || null;
|
||||
if (node) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { connections, node, nodeResolved };
|
||||
} catch (error) {
|
||||
console.log("Error fetching connections:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProxyConfigData(): Promise<{ config: any } | null> {
|
||||
try {
|
||||
const res = await fetch("/api/settings/proxy", { cache: "no-store" });
|
||||
if (res.ok) return { config: await res.json() };
|
||||
return { config: null };
|
||||
} catch {
|
||||
// Proxy indicators are best-effort — keep whatever is currently shown.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveConnectionProxies(
|
||||
conns: { id?: string }[]
|
||||
): Promise<Record<string, { proxy: any; level: string } | null> | null> {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
conns
|
||||
.filter((c) => c.id)
|
||||
.map((c) =>
|
||||
fetch(`/api/settings/proxy?resolve=${encodeURIComponent(c.id!)}`, { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => [c.id!, data] as [string, any])
|
||||
.catch(() => [c.id!, null] as [string, any])
|
||||
)
|
||||
);
|
||||
const map: Record<string, { proxy: any; level: string } | null> = {};
|
||||
for (const [id, data] of results) {
|
||||
map[id] = data?.proxy ? data : null;
|
||||
}
|
||||
return map;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ──── types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -210,93 +299,50 @@ export function useProviderConnections(
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const fetchProxyConfig = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings/proxy", { cache: "no-store" });
|
||||
if (res.ok) {
|
||||
setProxyConfig(await res.json());
|
||||
} else {
|
||||
setProxyConfig(null);
|
||||
}
|
||||
} catch {
|
||||
// Proxy indicators are best-effort.
|
||||
}
|
||||
const result = await loadProxyConfigData();
|
||||
if (result) setProxyConfig(result.config);
|
||||
}, []);
|
||||
|
||||
const fetchConnections = useCallback(async () => {
|
||||
try {
|
||||
const connectionsUrl = getProviderConnectionsRequestUrl(providerId);
|
||||
const [connectionsRes, nodesRes] = await Promise.all([
|
||||
fetch(connectionsUrl, { cache: "no-store" }),
|
||||
fetch("/api/provider-nodes", { cache: "no-store" }),
|
||||
]);
|
||||
const connectionsData = await connectionsRes.json();
|
||||
const nodesData = await nodesRes.json();
|
||||
if (connectionsRes.ok) {
|
||||
const filtered = (connectionsData.connections || []).filter((c: any) =>
|
||||
connectionBelongsToProviderPage(c.provider, providerId)
|
||||
);
|
||||
setConnections(filtered);
|
||||
}
|
||||
if (nodesRes.ok) {
|
||||
let node = (nodesData.nodes || []).find((entry: any) => entry.id === providerId) || null;
|
||||
|
||||
// Newly created compatible nodes can be briefly unavailable on one worker.
|
||||
if (!node && isCompatible) {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
const retryRes = await fetch("/api/provider-nodes", { cache: "no-store" });
|
||||
if (!retryRes.ok) continue;
|
||||
const retryData = await retryRes.json();
|
||||
node = (retryData.nodes || []).find((entry: any) => entry.id === providerId) || null;
|
||||
if (node) break;
|
||||
}
|
||||
}
|
||||
|
||||
setProviderNode(node);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching connections:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const result = await loadProviderConnectionsData(providerId, isCompatible);
|
||||
if (result) {
|
||||
if (result.connections) setConnections(result.connections);
|
||||
if (result.nodeResolved) setProviderNode(result.node);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [providerId, isCompatible]);
|
||||
|
||||
const loadConnProxies = useCallback(async (conns: { id?: string }[]) => {
|
||||
if (!conns.length) return;
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
conns
|
||||
.filter((c) => c.id)
|
||||
.map((c) =>
|
||||
fetch(`/api/settings/proxy?resolve=${encodeURIComponent(c.id!)}`, { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => [c.id!, data] as [string, any])
|
||||
.catch(() => [c.id!, null] as [string, any])
|
||||
)
|
||||
);
|
||||
const map: Record<string, { proxy: any; level: string } | null> = {};
|
||||
for (const [id, data] of results) {
|
||||
map[id] = data?.proxy ? data : null;
|
||||
}
|
||||
setConnProxyMap(map);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── effects ──────────────────────────────────────────────────────────────
|
||||
// The async work is defined INSIDE each effect (a component-scope loader
|
||||
// called synchronously from an effect is rejected by the compiler rules);
|
||||
// every setState below runs after an await.
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnections();
|
||||
void fetchProxyConfig();
|
||||
}, [fetchConnections, fetchProxyConfig]);
|
||||
const run = async () => {
|
||||
const result = await loadProviderConnectionsData(providerId, isCompatible);
|
||||
if (result) {
|
||||
if (result.connections) setConnections(result.connections);
|
||||
if (result.nodeResolved) setProviderNode(result.node);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
void run();
|
||||
const runProxyConfig = async () => {
|
||||
const result = await loadProxyConfigData();
|
||||
if (result) setProxyConfig(result.config);
|
||||
};
|
||||
void runProxyConfig();
|
||||
}, [providerId, isCompatible]);
|
||||
|
||||
// Per-connection proxy (handles registry assignments)
|
||||
useEffect(() => {
|
||||
if (!loading && connections.length > 0) {
|
||||
void loadConnProxies(connections);
|
||||
}
|
||||
}, [loading, connections, loadConnProxies]);
|
||||
if (loading || connections.length === 0) return;
|
||||
const run = async () => {
|
||||
const map = await resolveConnectionProxies(connections);
|
||||
if (map) setConnProxyMap(map);
|
||||
};
|
||||
void run();
|
||||
}, [loading, connections]);
|
||||
|
||||
// Upstream proxy routing config (native / CLIProxyAPI / Dario / fallback)
|
||||
useEffect(() => {
|
||||
@@ -546,7 +592,11 @@ export function useProviderConnections(
|
||||
const data = await res.json().catch(() => ({}));
|
||||
notify.error(
|
||||
data.error ||
|
||||
providerText(t, "failedUpdateCliproxyRouting", "Failed to update upstream proxy routing")
|
||||
providerText(
|
||||
t,
|
||||
"failedUpdateCliproxyRouting",
|
||||
"Failed to update upstream proxy routing"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,32 @@ import {
|
||||
providerText,
|
||||
} from "../providerPageHelpers";
|
||||
|
||||
// Shared /api/settings fetch with error-as-value semantics so the loaders
|
||||
// below only touch state after the await (no synchronous setState reachable
|
||||
// from the load effects).
|
||||
async function fetchSettingsPayload(): Promise<{
|
||||
ok: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
message?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch("/api/settings", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Settings request failed with HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new Error("Settings response was empty");
|
||||
}
|
||||
return { ok: true, data };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : "Failed to load settings",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ──── types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UseProviderSettingsReturn {
|
||||
@@ -73,6 +99,19 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
|
||||
>(null);
|
||||
const [savingClaudeRoutingPreference, setSavingClaudeRoutingPreference] = useState(false);
|
||||
|
||||
// Reset the per-provider load flags when the provider changes — a
|
||||
// render-phase adjustment guarded by the previous providerId (react.dev
|
||||
// "adjusting state when a prop changes"), replacing the synchronous resets
|
||||
// that used to run inside the load effects.
|
||||
const [settingsProviderId, setSettingsProviderId] = useState(providerId);
|
||||
if (settingsProviderId !== providerId) {
|
||||
setSettingsProviderId(providerId);
|
||||
setCodexSettingsLoaded(false);
|
||||
setCodexSettingsLoadError(null);
|
||||
setClaudeRoutingSettingsLoaded(false);
|
||||
setClaudeRoutingSettingsLoadError(null);
|
||||
}
|
||||
|
||||
// ── derived ──────────────────────────────────────────────────────────────
|
||||
const codexGlobalServiceModeOptions = useMemo(
|
||||
() =>
|
||||
@@ -89,75 +128,86 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
|
||||
codexSettingsRequestSeqRef.current = requestSeq;
|
||||
const isCurrentRequest = () => codexSettingsRequestSeqRef.current === requestSeq;
|
||||
|
||||
if (providerId !== "codex") {
|
||||
// Non-codex providers keep the initial false/null flags (also restored by
|
||||
// the render-phase reset above when providerId changes).
|
||||
if (providerId !== "codex") return;
|
||||
|
||||
const outcome = await fetchSettingsPayload();
|
||||
if (!isCurrentRequest()) return;
|
||||
if (!outcome.ok) {
|
||||
setCodexSettingsLoaded(false);
|
||||
setCodexSettingsLoadError(null);
|
||||
setCodexSettingsLoadError(outcome.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setCodexSettingsLoaded(false);
|
||||
const resolvedCodexServiceTier = resolveCodexGlobalFastServiceTier(outcome.data);
|
||||
setCodexGlobalServiceMode(getCodexGlobalServiceMode(outcome.data));
|
||||
setCodexGlobalSupportedModels([...resolvedCodexServiceTier.supportedModels]);
|
||||
setCodexSettingsLoadError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/settings", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Settings request failed with HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new Error("Settings response was empty");
|
||||
}
|
||||
if (!isCurrentRequest()) return;
|
||||
const resolvedCodexServiceTier = resolveCodexGlobalFastServiceTier(data);
|
||||
setCodexGlobalServiceMode(getCodexGlobalServiceMode(data));
|
||||
setCodexGlobalSupportedModels([...resolvedCodexServiceTier.supportedModels]);
|
||||
setCodexSettingsLoaded(true);
|
||||
} catch (error) {
|
||||
if (!isCurrentRequest()) return;
|
||||
setCodexSettingsLoaded(false);
|
||||
setCodexSettingsLoadError(error instanceof Error ? error.message : "Failed to load settings");
|
||||
}
|
||||
setCodexSettingsLoaded(true);
|
||||
}, [providerId]);
|
||||
|
||||
// The async work is duplicated INSIDE the effect (calling the exposed
|
||||
// loadCodexSettings callback synchronously from an effect is rejected by the
|
||||
// compiler rules); every setState here runs after the await.
|
||||
useEffect(() => {
|
||||
void loadCodexSettings();
|
||||
}, [loadCodexSettings]);
|
||||
if (providerId !== "codex") return;
|
||||
const requestSeq = codexSettingsRequestSeqRef.current + 1;
|
||||
codexSettingsRequestSeqRef.current = requestSeq;
|
||||
const isCurrentRequest = () => codexSettingsRequestSeqRef.current === requestSeq;
|
||||
const run = async () => {
|
||||
const outcome = await fetchSettingsPayload();
|
||||
if (!isCurrentRequest()) return;
|
||||
if (!outcome.ok) {
|
||||
setCodexSettingsLoaded(false);
|
||||
setCodexSettingsLoadError(outcome.message);
|
||||
return;
|
||||
}
|
||||
const resolvedCodexServiceTier = resolveCodexGlobalFastServiceTier(outcome.data);
|
||||
setCodexGlobalServiceMode(getCodexGlobalServiceMode(outcome.data));
|
||||
setCodexGlobalSupportedModels([...resolvedCodexServiceTier.supportedModels]);
|
||||
setCodexSettingsLoadError(null);
|
||||
setCodexSettingsLoaded(true);
|
||||
};
|
||||
void run();
|
||||
}, [providerId]);
|
||||
|
||||
// ── Claude routing settings loader ───────────────────────────────────────
|
||||
const loadClaudeRoutingSettings = useCallback(async () => {
|
||||
if (providerId !== "claude") {
|
||||
// Non-claude providers keep the initial false/null flags (also restored by
|
||||
// the render-phase reset above when providerId changes).
|
||||
if (providerId !== "claude") return;
|
||||
|
||||
const outcome = await fetchSettingsPayload();
|
||||
if (!outcome.ok) {
|
||||
setClaudeRoutingSettingsLoaded(false);
|
||||
setClaudeRoutingSettingsLoadError(null);
|
||||
setClaudeRoutingSettingsLoadError(outcome.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setClaudeRoutingSettingsLoaded(false);
|
||||
setPreferClaudeCodeForUnprefixedClaudeModels(
|
||||
outcome.data.preferClaudeCodeForUnprefixedClaudeModels === true
|
||||
);
|
||||
setClaudeRoutingSettingsLoadError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/settings", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Settings request failed with HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new Error("Settings response was empty");
|
||||
}
|
||||
setPreferClaudeCodeForUnprefixedClaudeModels(
|
||||
data.preferClaudeCodeForUnprefixedClaudeModels === true
|
||||
);
|
||||
setClaudeRoutingSettingsLoaded(true);
|
||||
} catch (error) {
|
||||
setClaudeRoutingSettingsLoaded(false);
|
||||
setClaudeRoutingSettingsLoadError(
|
||||
error instanceof Error ? error.message : "Failed to load settings"
|
||||
);
|
||||
}
|
||||
setClaudeRoutingSettingsLoaded(true);
|
||||
}, [providerId]);
|
||||
|
||||
// Same inline-in-effect shape as the codex loader above.
|
||||
useEffect(() => {
|
||||
void loadClaudeRoutingSettings();
|
||||
}, [loadClaudeRoutingSettings]);
|
||||
if (providerId !== "claude") return;
|
||||
const run = async () => {
|
||||
const outcome = await fetchSettingsPayload();
|
||||
if (!outcome.ok) {
|
||||
setClaudeRoutingSettingsLoaded(false);
|
||||
setClaudeRoutingSettingsLoadError(outcome.message);
|
||||
return;
|
||||
}
|
||||
setPreferClaudeCodeForUnprefixedClaudeModels(
|
||||
outcome.data.preferClaudeCodeForUnprefixedClaudeModels === true
|
||||
);
|
||||
setClaudeRoutingSettingsLoadError(null);
|
||||
setClaudeRoutingSettingsLoaded(true);
|
||||
};
|
||||
void run();
|
||||
}, [providerId]);
|
||||
|
||||
// ── Codex service mode handler ───────────────────────────────────────────
|
||||
const handleChangeCodexGlobalServiceMode = async (mode: CodexGlobalServiceMode) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Badge, Button, Input, Modal, Select, Toggle } from "@/shared/components";
|
||||
@@ -133,15 +133,25 @@ export default function AddCompatibleProviderModal({
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setFormData(createInitialForm(mode));
|
||||
setValidationResult(null);
|
||||
setCheckKey("");
|
||||
setShowAdvanced(false);
|
||||
setSaveError(null);
|
||||
setIconUrlError(null);
|
||||
}, [isOpen, mode]);
|
||||
// Fresh form on every open (and on a mode switch while open) — applied as a
|
||||
// render-phase adjustment guarded by the previously initialized mode
|
||||
// (react.dev "adjusting state when a prop changes") instead of a
|
||||
// synchronous-setState effect. Closing clears the marker so the next open
|
||||
// re-initializes again.
|
||||
const [initializedFor, setInitializedFor] = useState<{ mode: CompatibleMode } | null>(null);
|
||||
if (isOpen) {
|
||||
if (initializedFor?.mode !== mode) {
|
||||
setInitializedFor({ mode });
|
||||
setFormData(createInitialForm(mode));
|
||||
setValidationResult(null);
|
||||
setCheckKey("");
|
||||
setShowAdvanced(false);
|
||||
setSaveError(null);
|
||||
setIconUrlError(null);
|
||||
}
|
||||
} else if (initializedFor !== null) {
|
||||
setInitializedFor(null);
|
||||
}
|
||||
|
||||
const modalTitle =
|
||||
title ||
|
||||
|
||||
@@ -133,10 +133,7 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
|
||||
}, [providerId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!providerId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!providerId) return;
|
||||
return load();
|
||||
}, [providerId, load]);
|
||||
|
||||
@@ -152,5 +149,7 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
|
||||
load();
|
||||
}, [providerId, load]);
|
||||
|
||||
return { models, loading, error, retry };
|
||||
// Without a providerId nothing ever loads, so the exposed loading flag is
|
||||
// derived instead of being reset synchronously inside the effect above.
|
||||
return { models, loading: providerId ? loading : false, error, retry };
|
||||
}
|
||||
|
||||
@@ -51,26 +51,32 @@ export function useProviderUrlFilters({
|
||||
activeServiceKind,
|
||||
setActiveServiceKind,
|
||||
}: UseProviderUrlFiltersArgs): { displayModePreferenceReady: boolean } {
|
||||
const [displayModePreferenceReady, setDisplayModePreferenceReady] = useState(false);
|
||||
const [filtersHydrated, setFiltersHydrated] = useState(false);
|
||||
// Snapshot of the stored display-mode preference, read once via a lazy
|
||||
// initializer (localStorage must not be read during render). After the first
|
||||
// hydration the URL always carries the mode, so the fallback is mount-only.
|
||||
const [storedDisplayModePreference] = useState<ProviderDisplayMode>(() =>
|
||||
readProviderDisplayModePreference()
|
||||
);
|
||||
const [hydratedFromParams, setHydratedFromParams] = useState<ReadonlyURLSearchParams | null>(
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const urlMode = readProviderFiltersFromUrl(searchParams).displayMode;
|
||||
setProviderDisplayMode(urlMode ?? readProviderDisplayModePreference());
|
||||
setDisplayModePreferenceReady(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
// URL → state hydration as a render-phase adjustment guarded by the
|
||||
// previously hydrated params object (react.dev "adjusting state when a prop
|
||||
// changes") — replaces the two synchronous setState effects keyed on
|
||||
// searchParams, and removes the transient default-state first paint.
|
||||
if (hydratedFromParams !== searchParams) {
|
||||
setHydratedFromParams(searchParams);
|
||||
const urlFilters = readProviderFiltersFromUrl(searchParams);
|
||||
setProviderDisplayMode(urlFilters.displayMode ?? storedDisplayModePreference);
|
||||
setSearchQuery(urlFilters.searchQuery ?? "");
|
||||
setModelSearchQuery(urlFilters.modelSearchQuery ?? "");
|
||||
setActiveCategory(urlFilters.category ?? null);
|
||||
setShowFreeOnly(urlFilters.showFreeOnly ?? false);
|
||||
setActiveServiceKind(urlFilters.mediaKind ?? null);
|
||||
setFiltersHydrated(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
}
|
||||
const displayModePreferenceReady = hydratedFromParams !== null;
|
||||
const filtersHydrated = displayModePreferenceReady;
|
||||
|
||||
useEffect(() => {
|
||||
if (!filtersHydrated || !displayModePreferenceReady) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
export const RISK_ACKNOWLEDGED_STORAGE_KEY = "omniroute-risk-acknowledged";
|
||||
|
||||
@@ -53,22 +53,38 @@ export function isRiskAcknowledged(providerId: string): boolean {
|
||||
return readRiskAcknowledgedMap()[providerId] === true;
|
||||
}
|
||||
|
||||
// localStorage is a mutable external store, so the hook below subscribes to it
|
||||
// through useSyncExternalStore instead of mirroring it into component state
|
||||
// with an effect (which required a synchronous setState on providerId change).
|
||||
const riskAcknowledgedListeners = new Set<() => void>();
|
||||
|
||||
function subscribeToRiskAcknowledged(listener: () => void): () => void {
|
||||
riskAcknowledgedListeners.add(listener);
|
||||
return () => {
|
||||
riskAcknowledgedListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function emitRiskAcknowledgedChange(): void {
|
||||
for (const listener of riskAcknowledgedListeners) listener();
|
||||
}
|
||||
|
||||
export function acknowledgeProviderRisk(providerId: string): void {
|
||||
const map = readRiskAcknowledgedMap();
|
||||
map[providerId] = true;
|
||||
writeRiskAcknowledgedMap(map);
|
||||
emitRiskAcknowledgedChange();
|
||||
}
|
||||
|
||||
export function useRiskAcknowledged(providerId: string) {
|
||||
const [acknowledged, setAcknowledged] = useState(() => isRiskAcknowledged(providerId));
|
||||
|
||||
useEffect(() => {
|
||||
setAcknowledged(isRiskAcknowledged(providerId));
|
||||
}, [providerId]);
|
||||
const acknowledged = useSyncExternalStore(
|
||||
subscribeToRiskAcknowledged,
|
||||
() => isRiskAcknowledged(providerId),
|
||||
() => false
|
||||
);
|
||||
|
||||
const acknowledge = useCallback(() => {
|
||||
acknowledgeProviderRisk(providerId);
|
||||
setAcknowledged(true);
|
||||
}, [providerId]);
|
||||
|
||||
return { acknowledged, acknowledge };
|
||||
|
||||
@@ -54,9 +54,7 @@ const AddCompatibleProviderModal = dynamic(
|
||||
import { CategoryDot } from "./components/CategoryDot";
|
||||
const ImportProvidersFromFileModal = dynamic(
|
||||
() =>
|
||||
import("./components/ImportProvidersFromFileModal").then(
|
||||
(m) => m.ImportProvidersFromFileModal
|
||||
),
|
||||
import("./components/ImportProvidersFromFileModal").then((m) => m.ImportProvidersFromFileModal),
|
||||
{ ssr: false }
|
||||
);
|
||||
import NoAuthProvidersSection from "./components/NoAuthProvidersSection";
|
||||
@@ -192,6 +190,27 @@ function getConnectionErrorTag(connection, t: ProviderMessageTranslator) {
|
||||
return "ERR";
|
||||
}
|
||||
|
||||
// OAuth-env repair status fetch, extracted so the callback below only sets
|
||||
// state after the await (errors come back as `null` instead of a setState
|
||||
// inside the catch block, which the react-hooks compiler rules reject when the
|
||||
// callback is invoked from an effect).
|
||||
async function loadOauthEnvRepairStatus(): Promise<{
|
||||
available: boolean;
|
||||
missingCount: number;
|
||||
} | null> {
|
||||
try {
|
||||
const res = await fetch("/api/system/env/repair", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) return null;
|
||||
return {
|
||||
available: Boolean(data.available),
|
||||
missingCount: Number(data.missingCount || 0),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProvidersPage() {
|
||||
const router = useRouter();
|
||||
const [connections, setConnections] = useState<any[]>([]);
|
||||
@@ -297,33 +316,30 @@ export default function ProvidersPage() {
|
||||
writeProviderDisplayModePreference(storedDisplayMode);
|
||||
}, [connections.length, displayModePreferenceReady, providerDisplayMode, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldSyncProviderDisplayMode(displayModePreferenceReady, loading)) return;
|
||||
if (connections.length === 0 && providerDisplayMode === "configured") {
|
||||
setProviderDisplayMode("all");
|
||||
}
|
||||
}, [connections.length, displayModePreferenceReady, providerDisplayMode, loading]);
|
||||
// "No connections → fall back to the 'all' view" is a state adjustment
|
||||
// derived from other state, applied during render (self-invalidating guard,
|
||||
// converges in one extra pass) instead of a synchronous setState effect.
|
||||
if (
|
||||
shouldSyncProviderDisplayMode(displayModePreferenceReady, loading) &&
|
||||
connections.length === 0 &&
|
||||
providerDisplayMode === "configured"
|
||||
) {
|
||||
setProviderDisplayMode("all");
|
||||
}
|
||||
|
||||
const fetchOauthEnvRepairStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/system/env/repair", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setOauthEnvRepairStatus({
|
||||
available: Boolean(data.available),
|
||||
missingCount: Number(data.missingCount || 0),
|
||||
});
|
||||
} else {
|
||||
setOauthEnvRepairStatus(null);
|
||||
}
|
||||
} catch {
|
||||
setOauthEnvRepairStatus(null);
|
||||
}
|
||||
setOauthEnvRepairStatus(await loadOauthEnvRepairStatus());
|
||||
}, []);
|
||||
|
||||
// Inline-in-effect (calling the component-scope callback synchronously from
|
||||
// an effect is rejected by the compiler rules); setState runs after the await.
|
||||
useEffect(() => {
|
||||
void fetchOauthEnvRepairStatus();
|
||||
}, [fetchOauthEnvRepairStatus]);
|
||||
const run = async () => {
|
||||
const status = await loadOauthEnvRepairStatus();
|
||||
setOauthEnvRepairStatus(status);
|
||||
};
|
||||
void run();
|
||||
}, []);
|
||||
|
||||
const handleRepairEnv = async () => {
|
||||
if (!oauthEnvRepairStatus?.available || repairingEnv) return;
|
||||
|
||||
@@ -18,7 +18,11 @@ const STATE_LABELS: Record<CliproxyAccountHealthResult["state"], string> = {
|
||||
};
|
||||
|
||||
function AccountRow({ account }: { account: CliproxyAccountHealth }) {
|
||||
const state = account.disabled ? "Disabled" : account.unavailable ? "Unavailable" : account.status;
|
||||
const state = account.disabled
|
||||
? "Disabled"
|
||||
: account.unavailable
|
||||
? "Unavailable"
|
||||
: account.status;
|
||||
return (
|
||||
<li className="flex flex-wrap items-center justify-between gap-3 border-t border-border py-3 first:border-t-0">
|
||||
<div className="min-w-0">
|
||||
@@ -44,33 +48,50 @@ function AccountRow({ account }: { account: CliproxyAccountHealth }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Network + parse concerns live outside the component so the load callback only
|
||||
// sets state after the await (no synchronous setState reachable from the effect).
|
||||
async function fetchAccountHealth(): Promise<CliproxyAccountHealthResult> {
|
||||
try {
|
||||
const response = await fetch("/api/services/cliproxy/accounts", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return await response.json();
|
||||
} catch {
|
||||
return { state: "unreachable", accounts: [], version: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function CliproxyAccountHealthCard() {
|
||||
const [result, setResult] = useState<CliproxyAccountHealthResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/services/cliproxy/accounts", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
setResult(await response.json());
|
||||
} catch {
|
||||
setResult({ state: "unreachable", accounts: [], version: null });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const next = await fetchAccountHealth();
|
||||
setResult(next);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// Inline-in-effect (calling the component-scope `load` callback synchronously
|
||||
// from an effect is rejected by the compiler rules); setState is post-await.
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
const next = await fetchAccountHealth();
|
||||
setResult(next);
|
||||
setLoading(false);
|
||||
};
|
||||
void run();
|
||||
}, []);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setLoading(true);
|
||||
void load();
|
||||
}, [load]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="CLIProxyAPI accounts"
|
||||
subtitle="Read-only status from the authenticated management API"
|
||||
action={
|
||||
<Button variant="secondary" size="sm" onClick={() => void load()} loading={loading}>
|
||||
<Button variant="secondary" size="sm" onClick={handleRefresh} loading={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
@@ -87,7 +108,9 @@ export function CliproxyAccountHealthCard() {
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">
|
||||
{loading && !result ? "Loading account health…" : STATE_LABELS[result?.state ?? "unreachable"]}
|
||||
{loading && !result
|
||||
? "Loading account health…"
|
||||
: STATE_LABELS[result?.state ?? "unreachable"]}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -65,9 +65,43 @@ function formatExpiry(acc: DarioAccount): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Network + parse concerns extracted so the refresh callbacks only set state
|
||||
// after the await — the mount effect can then call them without a synchronous
|
||||
// setState (errors come back as values instead of catch-block state writes).
|
||||
async function fetchDarioAccounts(): Promise<{ accounts: DarioAccount[] } | { error: string }> {
|
||||
try {
|
||||
const res = await fetch("/api/services/dario/admin/accounts");
|
||||
const json = (await res.json().catch(() => null)) as {
|
||||
accounts?: DarioAccount[];
|
||||
error?: string;
|
||||
} | null;
|
||||
if (!res.ok) {
|
||||
throw new Error(json?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return { accounts: Array.isArray(json?.accounts) ? json!.accounts : [] };
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOmniConnectionsList(): Promise<OmniConnection[] | null> {
|
||||
try {
|
||||
const res = await fetch("/api/services/dario/admin/import-from-omniroute");
|
||||
const json = (await res.json().catch(() => null)) as {
|
||||
connections?: OmniConnection[];
|
||||
error?: string;
|
||||
} | null;
|
||||
if (!res.ok) return null;
|
||||
return Array.isArray(json?.connections) ? json!.connections : [];
|
||||
} catch {
|
||||
/* non-fatal — import section just stays empty */
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function DarioAccountPanel() {
|
||||
const [accounts, setAccounts] = useState<DarioAccount[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [pending, setPending] = useState<PendingLogin | null>(null);
|
||||
@@ -77,51 +111,48 @@ export function DarioAccountPanel() {
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const [omniConnections, setOmniConnections] = useState<OmniConnection[]>([]);
|
||||
const [omniLoading, setOmniLoading] = useState(false);
|
||||
const [omniLoading, setOmniLoading] = useState(true);
|
||||
const [importBusyId, setImportBusyId] = useState<string | null>(null);
|
||||
|
||||
const refreshAccounts = useCallback(async () => {
|
||||
const outcome = await fetchDarioAccounts();
|
||||
if ("accounts" in outcome) {
|
||||
setAccounts(outcome.accounts);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(outcome.error);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// Inline-in-effect (calling the component-scope refresh callbacks
|
||||
// synchronously from an effect is rejected by the compiler rules); every
|
||||
// setState here runs after an await.
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
const outcome = await fetchDarioAccounts();
|
||||
if ("accounts" in outcome) {
|
||||
setAccounts(outcome.accounts);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(outcome.error);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
void run();
|
||||
const runOmni = async () => {
|
||||
const list = await fetchOmniConnectionsList();
|
||||
if (list) setOmniConnections(list);
|
||||
setOmniLoading(false);
|
||||
};
|
||||
void runOmni();
|
||||
}, []);
|
||||
|
||||
const handleRefreshAccountsClick = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/services/dario/admin/accounts");
|
||||
const json = (await res.json().catch(() => null)) as {
|
||||
accounts?: DarioAccount[];
|
||||
error?: string;
|
||||
} | null;
|
||||
if (!res.ok) {
|
||||
throw new Error(json?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
setAccounts(Array.isArray(json?.accounts) ? json!.accounts : []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshOmniConnections = useCallback(async () => {
|
||||
setOmniLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/services/dario/admin/import-from-omniroute");
|
||||
const json = (await res.json().catch(() => null)) as {
|
||||
connections?: OmniConnection[];
|
||||
error?: string;
|
||||
} | null;
|
||||
if (res.ok) {
|
||||
setOmniConnections(Array.isArray(json?.connections) ? json!.connections : []);
|
||||
}
|
||||
} catch {
|
||||
/* non-fatal — import section just stays empty */
|
||||
} finally {
|
||||
setOmniLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAccounts();
|
||||
void refreshOmniConnections();
|
||||
}, [refreshAccounts, refreshOmniConnections]);
|
||||
};
|
||||
|
||||
async function importFromOmniroute(connectionId: string) {
|
||||
setImportBusyId(connectionId);
|
||||
@@ -344,7 +375,7 @@ export function DarioAccountPanel() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
onClick={() => void refreshAccounts()}
|
||||
onClick={handleRefreshAccountsClick}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
@@ -32,6 +32,22 @@ export function paginateModels(
|
||||
return models.slice(start, start + pageSize);
|
||||
}
|
||||
|
||||
// Fetch + parse extracted from the component so errors surface as a return
|
||||
// value instead of state mutations inside catch/finally blocks.
|
||||
async function fetchServiceModels(
|
||||
refresh: boolean
|
||||
): Promise<{ ok: boolean; data?: ServiceModel[]; message?: string | null }> {
|
||||
try {
|
||||
const url = `/api/services/${NAME}/models${refresh ? "?refresh=true" : ""}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
return { ok: true, data: Array.isArray(body?.data) ? body.data : [] };
|
||||
} catch (err) {
|
||||
return { ok: false, message: err instanceof Error ? err.message : null };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function NinerouterModelList() {
|
||||
@@ -42,35 +58,48 @@ export function NinerouterModelList() {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchModels = useCallback(
|
||||
const loadModels = useCallback(
|
||||
async (refresh = false) => {
|
||||
if (refresh) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const url = `/api/services/${NAME}/models${refresh ? "?refresh=true" : ""}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
const data: ServiceModel[] = Array.isArray(body?.data) ? body.data : [];
|
||||
setModels(data);
|
||||
// All setState calls stay after the await so the mount effect below can
|
||||
// call this loader without a synchronous setState inside the effect; the
|
||||
// refresh button sets its spinner flags in its own handler instead.
|
||||
const outcome = await fetchServiceModels(refresh);
|
||||
if (outcome.ok) {
|
||||
setModels(outcome.data);
|
||||
setPage(1);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("modelsLoadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(outcome.message ?? t("modelsLoadFailed"));
|
||||
}
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
// Inline-in-effect (calling the component-scope loadModels callback
|
||||
// synchronously from an effect is rejected by the compiler rules); every
|
||||
// setState here runs after the await.
|
||||
useEffect(() => {
|
||||
void fetchModels(false);
|
||||
}, [fetchModels]);
|
||||
const run = async () => {
|
||||
const outcome = await fetchServiceModels(false);
|
||||
if (outcome.ok) {
|
||||
setModels(outcome.data);
|
||||
setPage(1);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(outcome.message ?? t("modelsLoadFailed"));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
void run();
|
||||
}, [t]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
setError(null);
|
||||
void loadModels(true);
|
||||
};
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(models.length / PAGE_SIZE));
|
||||
const visibleModels = paginateModels(models, page, PAGE_SIZE);
|
||||
@@ -92,7 +121,7 @@ export function NinerouterModelList() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => fetchModels(true)}
|
||||
onClick={handleRefresh}
|
||||
disabled={loading || refreshing}
|
||||
className="shrink-0"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user