mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-01 12:22:24 +03:00
chore(lint): batch 5 of #12146 — combos, endpoint, provider-stats, api-manager and costs react-hooks violations resolved (#12174)
* chore(lint): batch 5 of #12146 — resolve the react-hooks compiler violations in combos, endpoint, provider-stats, api-manager and costs 40 violations across 14 files, all real refactors (no eslint-disable, no new suppressions; the areas' react-hooks entries are deleted from the freeze): - set-state-in-effect (30): fetch-on-mount effects moved behind an async continuation (usePools, usePoolUsage, useApiKeyUsageLimits, Notion/Obsidian source cards, A2A/MCP dashboards, ComboControlCenterClient, provider-stats, combos modal loaders, ApiManager initial load); prop/state sync converted to state adjustment during render with prev tracking (ApiKeyUsageLimitCard, PoolWizard dimensions/reset/group snap, combos sortMethod, builder reset, builder stage guard, single-provider default, stale intelligent selection); localStorage reads became lazy useState initializers (combos usage guide). - immutability / TDZ (8): effects that scheduled fetchers declared below them moved after the declarations (EndpointPageClient, ApiManagerPageClient, combos mount load); fetchData relocated below the per-key fetchers it calls. - static-components (7): provider-stats SortIcon hoisted to module level. - preserve-manual-memoization (2): ApiManager blockedModels dep destructured to a local; provider-scope derivation memoized so downstream memos see a stable dependency. Validation: eslint (CI command with suppressions, --max-warnings 0) clean on the 14 files; dashboard typecheck within baseline; mutation gate no drift; area tests 208/208 (node) + 29/29 (vitest). Refs #12146 * chore(lint): batch 5 follow-up — hoist the render-adjustment predicates so the new-code complexity gates stay flat The render adjustments added one cyclomatic branch to combos/page.tsx and one cognitive point to PoolWizard (caught by the new-code gate on the committed work); the compound conditions now live in pure module-level predicates. * chore(lint): batch 5 follow-up 2 — PoolWizard render adjustments live in two small hooks One consolidated hook tripped max-lines-per-function (>80) and the cognitive budget; the dimensions and open/close adjustments now live in two focused hooks with a shared WizardSetters type, and the group snap stays inline (one branch). complexityNewCode=0, cognitiveComplexityNewCode=0. * test(quota): repoint the two PoolWizard structural pins at the render-adjustment hook quota-edit-opens-wizard anchored the pre-fill block on the old '} else if (editPool)' effect literal and quota-pool-wizard-edit expected a bare 'if (editPool)' that only existed there; both now anchor on the batch-5 structure (submit still branches via if (!editPool)).
This commit is contained in:
committed by
GitHub
parent
78fd3504dd
commit
b7a0c54139
@@ -889,15 +889,6 @@
|
||||
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 4
|
||||
},
|
||||
"react-hooks/preserve-manual-memoization": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx": {
|
||||
@@ -983,20 +974,9 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/combos/ComboControlCenterClient.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/combos/page.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 6
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 8
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx": {
|
||||
@@ -1019,11 +999,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/costs/components/ApiKeyUsageLimitCard.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/costs/costExplorerUtils.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
@@ -1037,24 +1012,6 @@
|
||||
"src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePoolUsage.ts": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePools.ts": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/costs/useApiKeyUsageLimits.ts": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/discovery/DiscoveryPageClient.tsx": {
|
||||
@@ -1062,31 +1019,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": {
|
||||
"react-hooks/immutability": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/endpoint/components/A2ADashboard.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/endpoint/components/MCPDashboard.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/endpoint/components/NotionSourceCard.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/endpoint/components/ObsidianSourceCard.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/free-provider-rankings/page.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
@@ -1178,14 +1110,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/provider-stats/page.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/static-components": {
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 2
|
||||
|
||||
@@ -263,13 +263,6 @@ export default function ApiManagerPageClient() {
|
||||
input?.focus({ preventScroll: true });
|
||||
}, [newKeyNameInputId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
fetchModels();
|
||||
fetchCombos();
|
||||
fetchConnections();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- initial dashboard load only
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAddModal || !nameError) return;
|
||||
requestAnimationFrame(() => {
|
||||
@@ -278,7 +271,12 @@ export default function ApiManagerPageClient() {
|
||||
}, [nameError, showAddModal]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveOnly(readActiveOnlyPreference());
|
||||
// Hydrate the persisted preference after mount, behind an async boundary
|
||||
// (react-hooks/set-state-in-effect) — same post-hydration timing as before.
|
||||
void (async () => {
|
||||
await Promise.resolve();
|
||||
setActiveOnly(readActiveOnlyPreference());
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -424,25 +422,6 @@ export default function ApiManagerPageClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/keys");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setKeys(data.keys || []);
|
||||
setAllowKeyReveal(data.allowKeyReveal === true);
|
||||
// Fetch usage stats after keys are loaded
|
||||
fetchUsageStats(data.keys || []);
|
||||
fetchSessionCounts(data.keys || []);
|
||||
fetchDeviceCounts(data.keys || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching keys:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsageStats = async (apiKeys: ApiKey[]) => {
|
||||
if (apiKeys.length === 0) return;
|
||||
try {
|
||||
@@ -545,6 +524,37 @@ export default function ApiManagerPageClient() {
|
||||
}
|
||||
};
|
||||
|
||||
// fetchData calls the three per-key fetchers above — declared after them so the
|
||||
// calls are not TDZ reads (react-hooks/immutability).
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/keys");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setKeys(data.keys || []);
|
||||
setAllowKeyReveal(data.allowKeyReveal === true);
|
||||
// Fetch usage stats after keys are loaded
|
||||
fetchUsageStats(data.keys || []);
|
||||
fetchSessionCounts(data.keys || []);
|
||||
fetchDeviceCounts(data.keys || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching keys:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial dashboard load — placed after the fetcher declarations so the effect does
|
||||
// not read them in their TDZ (react-hooks/immutability), behind an async boundary
|
||||
// (react-hooks/set-state-in-effect).
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await Promise.all([fetchData(), fetchModels(), fetchCombos(), fetchConnections()]);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- initial dashboard load only
|
||||
}, []);
|
||||
|
||||
const clearPageError = useCallback(() => setPageError(null), []);
|
||||
|
||||
const keyCounts = useMemo(() => computeApiKeyCounts(keys), [keys]);
|
||||
@@ -1738,9 +1748,12 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
|
||||
// Initialize state from props - component remounts when key prop changes
|
||||
const initialModels = Array.isArray(apiKey?.allowedModels) ? apiKey.allowedModels : [];
|
||||
// Destructured to a local so the memo dep matches what the compiler infers
|
||||
// (react-hooks/preserve-manual-memoization).
|
||||
const blockedModelsProp = apiKey?.blockedModels;
|
||||
const initialBlockedModels = useMemo(
|
||||
() => (Array.isArray(apiKey?.blockedModels) ? apiKey.blockedModels : []),
|
||||
[apiKey?.blockedModels]
|
||||
() => (Array.isArray(blockedModelsProp) ? blockedModelsProp : []),
|
||||
[blockedModelsProp]
|
||||
);
|
||||
const initialCombos = Array.isArray(apiKey?.allowedCombos)
|
||||
? apiKey.allowedCombos.filter((combo) => combo !== ALL_COMBOS_ACCESS_RULE)
|
||||
@@ -2080,8 +2093,12 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
|
||||
// Provider wildcards ("ollama-cloud/*") are counted as providers, not models.
|
||||
// Inherited children render selected via the owner lookup inside the list component.
|
||||
const { providerWildcards: selectedProviderScopes, exactModels: selectedExactModels } =
|
||||
restoreProviderScopeSelection(selectedModels);
|
||||
// Memoized so downstream memos see a stable, non-mutated dependency
|
||||
// (react-hooks/preserve-manual-memoization).
|
||||
const { providerWildcards: selectedProviderScopes, exactModels: selectedExactModels } = useMemo(
|
||||
() => restoreProviderScopeSelection(selectedModels),
|
||||
[selectedModels]
|
||||
);
|
||||
const selectedProviderCount = selectedProviderScopes.length;
|
||||
const selectedModelCount = selectedExactModels.length;
|
||||
const selectedCount = selectedModels.length;
|
||||
|
||||
@@ -279,7 +279,10 @@ export default function ComboControlCenterClient({ comboId }: { comboId: string
|
||||
}, [comboId, range, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// Async continuation — see react-hooks/set-state-in-effect.
|
||||
void (async () => {
|
||||
await load();
|
||||
})();
|
||||
}, [load]);
|
||||
|
||||
const summary = useMemo(
|
||||
|
||||
@@ -386,6 +386,17 @@ const STRATEGY_RECOMMENDATIONS_FALLBACK = {
|
||||
};
|
||||
|
||||
const COMBO_USAGE_GUIDE_STORAGE_KEY = "omniroute:combos:hide-usage-guide";
|
||||
|
||||
// Pure predicate hoisted out of the page component to keep its cyclomatic budget flat
|
||||
// (check:complexity new-code mode).
|
||||
function isStaleIntelligentSelection(
|
||||
intelligentCombos: Array<{ id: string }>,
|
||||
selectedId: string | null
|
||||
): boolean {
|
||||
if (selectedId === null) return false;
|
||||
if (intelligentCombos.length === 0) return true;
|
||||
return !intelligentCombos.some((combo) => combo.id === selectedId);
|
||||
}
|
||||
const COMBO_FORM_STAGE_META = [
|
||||
{
|
||||
id: "basics",
|
||||
@@ -749,7 +760,15 @@ export default function CombosPage() {
|
||||
const [proxyConfig, setProxyConfig] = useState(null);
|
||||
const { comboProxyAssignedIds, fetchComboProxyAssignments } = useComboProxyAssignments();
|
||||
const [providerNodes, setProviderNodes] = useState([]);
|
||||
const [showUsageGuide, setShowUsageGuide] = useState(true);
|
||||
const [showUsageGuide, setShowUsageGuide] = useState(() => {
|
||||
// Lazy initializer instead of a mount effect (react-hooks/set-state-in-effect).
|
||||
try {
|
||||
return globalThis.localStorage?.getItem(COMBO_USAGE_GUIDE_STORAGE_KEY) !== "1";
|
||||
} catch {
|
||||
// Ignore storage access errors (privacy mode / restricted environments)
|
||||
return true;
|
||||
}
|
||||
});
|
||||
const [recentlyCreatedCombo, setRecentlyCreatedCombo] = useState("");
|
||||
const [creatingKimiPreset, setCreatingKimiPreset] = useState(false);
|
||||
const [comboDragIndex, setComboDragIndex] = useState(null);
|
||||
@@ -781,45 +800,11 @@ export default function CombosPage() {
|
||||
return activeFilter === "intelligent" ? intelligentCombos[0] : null;
|
||||
}, [activeFilter, intelligentCombos, selectedIntelligentComboId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (intelligentCombos.length === 0) {
|
||||
setSelectedIntelligentComboId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedIntelligentComboId &&
|
||||
!intelligentCombos.some((combo) => combo.id === selectedIntelligentComboId)
|
||||
) {
|
||||
setSelectedIntelligentComboId(null);
|
||||
}
|
||||
}, [intelligentCombos, selectedIntelligentComboId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
fetch("/api/settings")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((settings) => setComboConfigMode(normalizeComboConfigMode(settings?.comboConfigMode)))
|
||||
.catch(() => setComboConfigMode("guided"));
|
||||
fetch("/api/settings/compression")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((settings) => setPromptCompressionEnabled(settings?.enabled === true))
|
||||
.catch(() => setPromptCompressionEnabled(false));
|
||||
fetch("/api/settings/proxy")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((c) => setProxyConfig(c))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (globalThis.localStorage?.getItem(COMBO_USAGE_GUIDE_STORAGE_KEY) === "1") {
|
||||
setShowUsageGuide(false);
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage access errors (privacy mode / restricted environments)
|
||||
}
|
||||
}, []);
|
||||
// Drop a stale selection when the list no longer contains it — state adjustment
|
||||
// during render (react-hooks/set-state-in-effect).
|
||||
if (isStaleIntelligentSelection(intelligentCombos, selectedIntelligentComboId)) {
|
||||
setSelectedIntelligentComboId(null);
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
@@ -848,6 +833,27 @@ export default function CombosPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Mount load — placed after fetchData so the effect does not read the binding in its
|
||||
// TDZ (react-hooks/immutability); the call sits behind an async boundary
|
||||
// (react-hooks/set-state-in-effect).
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await fetchData();
|
||||
})();
|
||||
fetch("/api/settings")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((settings) => setComboConfigMode(normalizeComboConfigMode(settings?.comboConfigMode)))
|
||||
.catch(() => setComboConfigMode("guided"));
|
||||
fetch("/api/settings/compression")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((settings) => setPromptCompressionEnabled(settings?.enabled === true))
|
||||
.catch(() => setPromptCompressionEnabled(false));
|
||||
fetch("/api/settings/proxy")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((c) => setProxyConfig(c))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (data) => {
|
||||
try {
|
||||
const res = await fetch("/api/combos", {
|
||||
@@ -2041,13 +2047,15 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
||||
(config.modelSort as { method?: unknown } | undefined)?.method
|
||||
);
|
||||
const [sortMethod, setSortMethod] = useState<SortMethod>(initialSortMethod);
|
||||
useEffect(() => {
|
||||
// Sync point: when the combo identity changes, re-derive sort method.
|
||||
// Manual edits via handleSortChange already set sortMethod inside resetFormForCombo,
|
||||
// but this guards the case where the modal is reused (edit-A→close→edit-B without unmount).
|
||||
// Sync point: when the combo identity changes, re-derive sort method — state
|
||||
// adjustment during render (react-hooks/set-state-in-effect). Manual edits via
|
||||
// handleSortChange already set sortMethod inside resetFormForCombo; this guards the
|
||||
// modal-reuse case (edit-A→close→edit-B without unmount).
|
||||
const [prevSortComboId, setPrevSortComboId] = useState(combo?.id);
|
||||
if (combo?.id !== prevSortComboId) {
|
||||
setPrevSortComboId(combo?.id);
|
||||
setSortMethod(normalizeSortMethod(combo?.config?.modelSort?.method));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [combo?.id]);
|
||||
}
|
||||
const modelsRef = useRef(models);
|
||||
const sortMethodRef = useRef<SortMethod>(sortMethod);
|
||||
const resetSortGenerationRef = useRef(0);
|
||||
@@ -2158,11 +2166,11 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
||||
contextLength,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!comboBuilderStages.includes(builderStage)) {
|
||||
setBuilderStage("strategy");
|
||||
}
|
||||
}, [builderStage, comboBuilderStages]);
|
||||
// Keep the stage on a real option — self-extinguishing state adjustment during
|
||||
// render (react-hooks/set-state-in-effect).
|
||||
if (!comboBuilderStages.includes(builderStage)) {
|
||||
setBuilderStage("strategy");
|
||||
}
|
||||
|
||||
const hasPricingForModel = useCallback(
|
||||
(modelValue) => {
|
||||
@@ -2395,37 +2403,40 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) fetchModalData();
|
||||
// Async continuation — see react-hooks/set-state-in-effect.
|
||||
if (isOpen) {
|
||||
void (async () => {
|
||||
await fetchModalData();
|
||||
})();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setBuilderProviderId("");
|
||||
setBuilderModelId("");
|
||||
setBuilderConnectionId(COMBO_BUILDER_AUTO_CONNECTION);
|
||||
setBuilderAllowedConnectionIds([]);
|
||||
setManualModelInput("");
|
||||
setManualModelError("");
|
||||
setBuilderComboRefName("");
|
||||
setBuilderError("");
|
||||
setBuilderStage("basics");
|
||||
}, [combo?.id, isOpen]);
|
||||
// Reset the builder inputs whenever the modal (re)opens or switches combos —
|
||||
// state adjustment during render (react-hooks/set-state-in-effect).
|
||||
const [prevBuilderResetKey, setPrevBuilderResetKey] = useState<{
|
||||
comboId: string | undefined;
|
||||
isOpen: boolean;
|
||||
}>({ comboId: combo?.id, isOpen });
|
||||
if (prevBuilderResetKey.comboId !== combo?.id || prevBuilderResetKey.isOpen !== isOpen) {
|
||||
setPrevBuilderResetKey({ comboId: combo?.id, isOpen });
|
||||
if (isOpen) {
|
||||
setBuilderProviderId("");
|
||||
setBuilderModelId("");
|
||||
setBuilderConnectionId(COMBO_BUILDER_AUTO_CONNECTION);
|
||||
setBuilderAllowedConnectionIds([]);
|
||||
setManualModelInput("");
|
||||
setManualModelError("");
|
||||
setBuilderComboRefName("");
|
||||
setBuilderError("");
|
||||
setBuilderStage("basics");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
if (combo) {
|
||||
resetFormForCombo(combo);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
createDraftStateRef.current = getEmptyCreateDraftSnapshot();
|
||||
resetFormForCombo(null, null);
|
||||
|
||||
const loadDefaults = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/settings/combo-defaults");
|
||||
@@ -2451,20 +2462,30 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
||||
}
|
||||
};
|
||||
|
||||
loadDefaults();
|
||||
// Async continuation — the compiler rejects sync calls to setter-capturing
|
||||
// callbacks from the effect body (react-hooks/set-state-in-effect).
|
||||
void (async () => {
|
||||
await Promise.resolve();
|
||||
if (cancelled) return;
|
||||
if (combo) {
|
||||
resetFormForCombo(combo);
|
||||
return;
|
||||
}
|
||||
createDraftStateRef.current = getEmptyCreateDraftSnapshot();
|
||||
resetFormForCombo(null, null);
|
||||
await loadDefaults();
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [combo, getEmptyCreateDraftSnapshot, isExpertMode, isOpen, resetFormForCombo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (builderProviderId) return;
|
||||
if (builderProviders.length === 1) {
|
||||
setBuilderProviderId(builderProviders[0].providerId);
|
||||
}
|
||||
}, [builderProviderId, builderProviders, isOpen]);
|
||||
// Default to the only available provider — self-extinguishing state adjustment
|
||||
// during render (react-hooks/set-state-in-effect).
|
||||
if (isOpen && !builderProviderId && builderProviders.length === 1) {
|
||||
setBuilderProviderId(builderProviders[0].providerId);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!strategyChangeMountedRef.current) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
@@ -88,8 +88,11 @@ export function ApiKeyUsageLimitCard({
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!payload) return;
|
||||
// Reset the form when a new payload arrives — state adjustment during render
|
||||
// (react-hooks/set-state-in-effect; see react.dev "adjusting state when a prop changes").
|
||||
const [prevPayload, setPrevPayload] = useState<typeof payload>(null);
|
||||
if (payload && payload !== prevPayload) {
|
||||
setPrevPayload(payload);
|
||||
setEnabled(payload.key.usageLimitEnabled);
|
||||
setDailyLimit(
|
||||
typeof payload.key.dailyUsageLimitUsd === "number"
|
||||
@@ -102,7 +105,7 @@ export function ApiKeyUsageLimitCard({
|
||||
: ""
|
||||
);
|
||||
setError(null);
|
||||
}, [payload]);
|
||||
}
|
||||
|
||||
const formatter = useMemo(() => createCurrencyFormatter(locale), [locale]);
|
||||
const status = payload?.status;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* Phase C1 — Quota Share Redesign.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Modal } from "@/shared/components";
|
||||
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
|
||||
@@ -172,6 +172,132 @@ function Stepper({ currentStep }: { currentStep: 1 | 2 | 3 }) {
|
||||
// Main component
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Pure helpers hoisted out of the component to keep its cognitive budget flat
|
||||
// (check:cognitive-complexity new-code mode).
|
||||
function pickWizardDimensions(
|
||||
primaryConnectionId: string | undefined,
|
||||
plans: Record<string, { dimensions: QuotaDimension[] } | undefined>,
|
||||
catalogDimensions: QuotaDimension[] | null
|
||||
): QuotaDimension[] {
|
||||
if (!primaryConnectionId) return [];
|
||||
const existingPlan = plans[primaryConnectionId];
|
||||
if (existingPlan && existingPlan.dimensions.length > 0) return [...existingPlan.dimensions];
|
||||
return catalogDimensions ? [...catalogDimensions] : [];
|
||||
}
|
||||
|
||||
// Pure predicate hoisted out of the component to keep its cognitive budget flat
|
||||
// (check:cognitive-complexity new-code mode): snap the group <select> to a real,
|
||||
// selectable option in create mode once groups load.
|
||||
function poolWizardNeedsGroupSnap(
|
||||
open: boolean,
|
||||
isEditing: boolean,
|
||||
groups: Array<{ id: string }>,
|
||||
groupId: string
|
||||
): boolean {
|
||||
if (!open || isEditing || groups.length === 0) return false;
|
||||
return groupId === "all" || !groups.some((g) => g.id === groupId);
|
||||
}
|
||||
|
||||
type WizardSetters = {
|
||||
setStep: Dispatch<SetStateAction<1 | 2 | 3>>;
|
||||
setConnectionIds: Dispatch<SetStateAction<string[]>>;
|
||||
setPoolName: Dispatch<SetStateAction<string>>;
|
||||
setDefaultPolicy: Dispatch<SetStateAction<Policy>>;
|
||||
setEditDimensions: Dispatch<SetStateAction<QuotaDimension[]>>;
|
||||
setDimensionsEdited: Dispatch<SetStateAction<boolean>>;
|
||||
setAllocations: Dispatch<SetStateAction<PoolAllocation[]>>;
|
||||
setExclusive: Dispatch<SetStateAction<boolean>>;
|
||||
setError: Dispatch<SetStateAction<string | null>>;
|
||||
setSaving: Dispatch<SetStateAction<boolean>>;
|
||||
setGroupId: Dispatch<SetStateAction<string>>;
|
||||
};
|
||||
|
||||
// Render-time state adjustment (react-hooks/set-state-in-effect): reload the editable
|
||||
// dimensions when the primary connection changes. Lives in a hook so the component's
|
||||
// complexity budget stays flat (check:complexity new-code mode).
|
||||
function useWizardDimensionsAdjustment({
|
||||
primaryConnectionId,
|
||||
selectedProvider,
|
||||
plans,
|
||||
setEditDimensions,
|
||||
setDimensionsEdited,
|
||||
}: {
|
||||
primaryConnectionId: string | undefined;
|
||||
selectedProvider: string | undefined;
|
||||
plans: Record<string, { dimensions: QuotaDimension[] } | undefined>;
|
||||
} & Pick<WizardSetters, "setEditDimensions" | "setDimensionsEdited">) {
|
||||
const [prevPrimaryConnectionId, setPrevPrimaryConnectionId] = useState(primaryConnectionId);
|
||||
if (primaryConnectionId !== prevPrimaryConnectionId) {
|
||||
setPrevPrimaryConnectionId(primaryConnectionId);
|
||||
const catalogPlan = selectedProvider ? getKnownPlan(selectedProvider) : null;
|
||||
setEditDimensions(
|
||||
pickWizardDimensions(primaryConnectionId, plans, catalogPlan?.dimensions ?? null)
|
||||
);
|
||||
setDimensionsEdited(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Render-time state adjustment keyed on the (open, editPool) pair — reset on close,
|
||||
// pre-fill on edit open — mirroring the old effect's deps (react-hooks/set-state-in-effect).
|
||||
function useWizardOpenCloseAdjustment({
|
||||
open,
|
||||
editPool,
|
||||
editPoolExclusive,
|
||||
initialGroupId,
|
||||
plans,
|
||||
...set
|
||||
}: {
|
||||
open: boolean;
|
||||
editPool?: QuotaPool;
|
||||
editPoolExclusive?: boolean;
|
||||
initialGroupId: string;
|
||||
plans: Record<string, { dimensions: QuotaDimension[] } | undefined>;
|
||||
} & WizardSetters) {
|
||||
const [prevWizardKey, setPrevWizardKey] = useState<{ open: boolean; editPool?: QuotaPool }>({
|
||||
open,
|
||||
editPool,
|
||||
});
|
||||
const changed = prevWizardKey.open !== open || prevWizardKey.editPool !== editPool;
|
||||
if (changed) setPrevWizardKey({ open, editPool });
|
||||
if (changed && !open) {
|
||||
// Closing: always reset to defaults.
|
||||
set.setStep(1);
|
||||
set.setConnectionIds([]);
|
||||
set.setPoolName("");
|
||||
set.setDefaultPolicy("hard");
|
||||
set.setEditDimensions([]);
|
||||
set.setDimensionsEdited(false);
|
||||
set.setAllocations([]);
|
||||
set.setExclusive(false);
|
||||
set.setError(null);
|
||||
set.setSaving(false);
|
||||
set.setGroupId(initialGroupId);
|
||||
}
|
||||
if (changed && open && editPool) {
|
||||
// Opening in edit mode: pre-fill from the existing pool. Preserves the pool's
|
||||
// exclusivity so editing an exclusive pool does not silently un-exclusive it;
|
||||
// dimensionsEdited stays false so the PUT is skipped unless the user edits.
|
||||
const ids =
|
||||
Array.isArray(editPool.connectionIds) && editPool.connectionIds.length > 0
|
||||
? editPool.connectionIds
|
||||
: [editPool.connectionId];
|
||||
const existingPlan = plans[ids[0]];
|
||||
set.setConnectionIds(ids);
|
||||
set.setPoolName(editPool.name);
|
||||
set.setGroupId(editPool.groupId ?? initialGroupId);
|
||||
set.setAllocations(editPool.allocations ?? []);
|
||||
set.setExclusive(editPoolExclusive ?? false);
|
||||
set.setEditDimensions(
|
||||
existingPlan && existingPlan.dimensions.length > 0 ? [...existingPlan.dimensions] : []
|
||||
);
|
||||
set.setDimensionsEdited(false);
|
||||
set.setError(null);
|
||||
set.setSaving(false);
|
||||
set.setStep(1);
|
||||
}
|
||||
// When open && !editPool (create mode): the close-reset above already restored defaults.
|
||||
}
|
||||
|
||||
export default function PoolWizard({
|
||||
open,
|
||||
onClose,
|
||||
@@ -248,83 +374,36 @@ export default function PoolWizard({
|
||||
[connections, existingPoolConnectionIds, lockedProvider]
|
||||
);
|
||||
|
||||
// ── Load dimensions when primary connection changes ───────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!primaryConnectionId) {
|
||||
setEditDimensions([]);
|
||||
setDimensionsEdited(false);
|
||||
return;
|
||||
}
|
||||
const existingPlan = plans[primaryConnectionId];
|
||||
if (existingPlan && existingPlan.dimensions.length > 0) {
|
||||
setEditDimensions([...existingPlan.dimensions]);
|
||||
} else {
|
||||
const catalogPlan = selectedConn ? getKnownPlan(selectedConn.provider) : null;
|
||||
setEditDimensions(catalogPlan ? [...catalogPlan.dimensions] : []);
|
||||
}
|
||||
setDimensionsEdited(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [primaryConnectionId]);
|
||||
|
||||
// ── Reset wizard on open/close ────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Closing: always reset to defaults.
|
||||
setStep(1);
|
||||
setConnectionIds([]);
|
||||
setPoolName("");
|
||||
setDefaultPolicy("hard");
|
||||
setEditDimensions([]);
|
||||
setDimensionsEdited(false);
|
||||
setAllocations([]);
|
||||
setExclusive(false);
|
||||
setError(null);
|
||||
setSaving(false);
|
||||
setGroupId(initialGroupId);
|
||||
} else if (editPool) {
|
||||
// Opening in edit mode: pre-fill from the existing pool.
|
||||
const ids =
|
||||
Array.isArray(editPool.connectionIds) && editPool.connectionIds.length > 0
|
||||
? editPool.connectionIds
|
||||
: [editPool.connectionId];
|
||||
setConnectionIds(ids);
|
||||
setPoolName(editPool.name);
|
||||
setGroupId(editPool.groupId ?? initialGroupId);
|
||||
setAllocations(editPool.allocations ?? []);
|
||||
// Preserve the pool's current exclusivity state so editing an exclusive pool
|
||||
// doesn't silently un-exclusive it. Falls back to false only when not provided.
|
||||
setExclusive(editPoolExclusive ?? false);
|
||||
// Pre-load plan dimensions for the primary connection (same as create path).
|
||||
// dimensionsEdited stays false so the PUT is skipped unless the user actively edits.
|
||||
const primaryId = ids[0];
|
||||
const existingPlan = plans[primaryId];
|
||||
if (existingPlan && existingPlan.dimensions.length > 0) {
|
||||
setEditDimensions([...existingPlan.dimensions]);
|
||||
} else {
|
||||
setEditDimensions([]);
|
||||
}
|
||||
setDimensionsEdited(false);
|
||||
setError(null);
|
||||
setSaving(false);
|
||||
setStep(1);
|
||||
}
|
||||
// When open && !editPool (create mode): the existing create-reset on close handles defaults.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, editPool, initialGroupId]);
|
||||
|
||||
// Keep the group <select> on a real, selectable option: if the inherited page
|
||||
// filter was "all" (or an unknown id), snap to the first real group once groups
|
||||
// load. Prevents persisting groupId="all" (which renders under no group → B1).
|
||||
useEffect(() => {
|
||||
if (!open || editPool) return;
|
||||
if (groups.length === 0) return;
|
||||
if (groupId === "all" || !groups.some((g) => g.id === groupId)) {
|
||||
setGroupId(groups[0].id);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, editPool, groups]);
|
||||
useWizardDimensionsAdjustment({
|
||||
primaryConnectionId,
|
||||
selectedProvider: selectedConn?.provider,
|
||||
plans,
|
||||
setEditDimensions,
|
||||
setDimensionsEdited,
|
||||
});
|
||||
useWizardOpenCloseAdjustment({
|
||||
open,
|
||||
editPool,
|
||||
editPoolExclusive,
|
||||
initialGroupId,
|
||||
plans,
|
||||
setStep,
|
||||
setConnectionIds,
|
||||
setPoolName,
|
||||
setDefaultPolicy,
|
||||
setEditDimensions,
|
||||
setDimensionsEdited,
|
||||
setAllocations,
|
||||
setExclusive,
|
||||
setError,
|
||||
setSaving,
|
||||
setGroupId,
|
||||
});
|
||||
// Keep the group <select> on a real, selectable option (create mode) — self-
|
||||
// extinguishing state adjustment during render (react-hooks/set-state-in-effect).
|
||||
if (poolWizardNeedsGroupSnap(open, Boolean(editPool), groups, groupId)) {
|
||||
setGroupId(groups[0].id);
|
||||
}
|
||||
|
||||
// ── Step 2 — dimension editors ────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -38,7 +38,11 @@ export function usePoolUsage(poolId: string, pollIntervalMs = 15_000): UsePoolUs
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void fetchUsage();
|
||||
// Async continuation: the compiler only accepts setter-capturing callbacks from an
|
||||
// effect when the call sits behind an async boundary (react-hooks/set-state-in-effect).
|
||||
void (async () => {
|
||||
await fetchUsage();
|
||||
})();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
void fetchUsage();
|
||||
|
||||
@@ -42,7 +42,10 @@ export function usePools(): UsePoolsResult {
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void fetchPools();
|
||||
// Async continuation — see usePoolUsage (react-hooks/set-state-in-effect).
|
||||
void (async () => {
|
||||
await fetchPools();
|
||||
})();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
|
||||
@@ -44,7 +44,11 @@ export function useApiKeyUsageLimits(selectedApiKeyId: string | null) {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// Async continuation — the compiler rejects a sync call to a setter-capturing
|
||||
// callback from the effect body (react-hooks/set-state-in-effect).
|
||||
void (async () => {
|
||||
await load();
|
||||
})();
|
||||
}, [load]);
|
||||
|
||||
return { payload, loading, save };
|
||||
|
||||
@@ -299,52 +299,6 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
[translateOrFallback]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const loadPage = async () => {
|
||||
const tunnelVisibility = await loadCloudSettings(() => mounted);
|
||||
|
||||
if (!mounted) return;
|
||||
setLoading(false);
|
||||
|
||||
runEndpointBackgroundTask("models", fetchModels);
|
||||
runEndpointBackgroundTask("protocol-status", fetchProtocolStatus);
|
||||
runEndpointBackgroundTask("search-providers", fetchSearchProviders);
|
||||
runEndpointBackgroundTask("network-info", async () => {
|
||||
try {
|
||||
const res = await fetch("/api/network/info");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (mounted) {
|
||||
if (data.localUrl) setLocalApiUrl(data.localUrl);
|
||||
setLanUrls(data.lanUrls ?? []);
|
||||
if (data.tailscaleIpUrl) setTailscaleIpUrl(data.tailscaleIpUrl);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
});
|
||||
|
||||
if (tunnelVisibility.showCloudflaredTunnel) {
|
||||
runEndpointBackgroundTask("cloudflared-status", () => fetchCloudflaredStatus(true));
|
||||
}
|
||||
if (tunnelVisibility.showTailscaleFunnel) {
|
||||
runEndpointBackgroundTask("tailscale-status", () => fetchTailscaleStatus(true));
|
||||
}
|
||||
if (tunnelVisibility.showNgrokTunnel) {
|
||||
runEndpointBackgroundTask("ngrok-status", () => fetchNgrokStatus(true));
|
||||
}
|
||||
};
|
||||
|
||||
void loadPage();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [fetchCloudflaredStatus, fetchTailscaleStatus, fetchNgrokStatus]);
|
||||
|
||||
const fetchModels = async () => {
|
||||
setModelsLoading(true);
|
||||
try {
|
||||
@@ -507,6 +461,55 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
return DEFAULT_TUNNEL_VISIBILITY;
|
||||
};
|
||||
|
||||
// Moved below the loader/fetcher declarations it schedules — referencing them from
|
||||
// an effect declared above their `const` bindings is a TDZ read the compiler rejects
|
||||
// (react-hooks/immutability).
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const loadPage = async () => {
|
||||
const tunnelVisibility = await loadCloudSettings(() => mounted);
|
||||
|
||||
if (!mounted) return;
|
||||
setLoading(false);
|
||||
|
||||
runEndpointBackgroundTask("models", fetchModels);
|
||||
runEndpointBackgroundTask("protocol-status", fetchProtocolStatus);
|
||||
runEndpointBackgroundTask("search-providers", fetchSearchProviders);
|
||||
runEndpointBackgroundTask("network-info", async () => {
|
||||
try {
|
||||
const res = await fetch("/api/network/info");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (mounted) {
|
||||
if (data.localUrl) setLocalApiUrl(data.localUrl);
|
||||
setLanUrls(data.lanUrls ?? []);
|
||||
if (data.tailscaleIpUrl) setTailscaleIpUrl(data.tailscaleIpUrl);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
});
|
||||
|
||||
if (tunnelVisibility.showCloudflaredTunnel) {
|
||||
runEndpointBackgroundTask("cloudflared-status", () => fetchCloudflaredStatus(true));
|
||||
}
|
||||
if (tunnelVisibility.showTailscaleFunnel) {
|
||||
runEndpointBackgroundTask("tailscale-status", () => fetchTailscaleStatus(true));
|
||||
}
|
||||
if (tunnelVisibility.showNgrokTunnel) {
|
||||
runEndpointBackgroundTask("ngrok-status", () => fetchNgrokStatus(true));
|
||||
}
|
||||
};
|
||||
|
||||
void loadPage();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [fetchCloudflaredStatus, fetchTailscaleStatus, fetchNgrokStatus]);
|
||||
|
||||
const handleCustomSystemPromptEnabledChange = (value: boolean) => {
|
||||
setCustomSystemPromptEnabled(value);
|
||||
void fetch("/api/settings", {
|
||||
@@ -2485,4 +2488,3 @@ function EndpointCard({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,12 @@ export default function A2ADashboardPage() {
|
||||
}, [offset, stateFilter, skillFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.allSettled([refreshStatus(), refreshTasks()]).finally(() => setLoading(false));
|
||||
// Async continuation — the compiler rejects sync calls to setter-capturing
|
||||
// callbacks from the effect body (react-hooks/set-state-in-effect).
|
||||
void (async () => {
|
||||
await Promise.allSettled([refreshStatus(), refreshTasks()]);
|
||||
setLoading(false);
|
||||
})();
|
||||
const interval = setInterval(() => {
|
||||
void refreshStatus();
|
||||
void refreshTasks();
|
||||
@@ -142,7 +147,10 @@ export default function A2ADashboardPage() {
|
||||
}, [refreshStatus, refreshTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshTasks();
|
||||
// Async continuation — see above.
|
||||
void (async () => {
|
||||
await refreshTasks();
|
||||
})();
|
||||
}, [refreshTasks]);
|
||||
|
||||
const availableSkills = useMemo(() => {
|
||||
|
||||
@@ -266,13 +266,19 @@ export default function McpDashboardPage() {
|
||||
}, [auditOffset, toolFilter, successFilter, apiKeyFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshSummary();
|
||||
// Async continuation — see react-hooks/set-state-in-effect.
|
||||
void (async () => {
|
||||
await refreshSummary();
|
||||
})();
|
||||
const interval = setInterval(refreshSummary, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshAudit();
|
||||
// Async continuation — see react-hooks/set-state-in-effect.
|
||||
void (async () => {
|
||||
await refreshAudit();
|
||||
})();
|
||||
}, [refreshAudit]);
|
||||
|
||||
const handleSwitchCombo = async () => {
|
||||
@@ -425,7 +431,9 @@ export default function McpDashboardPage() {
|
||||
<p>
|
||||
{t("scopesEnforced")}:{" "}
|
||||
<span className="font-semibold">
|
||||
{(status?.scopesEnforced ?? status?.heartbeat?.scopesEnforced) ? t("yes") : t("no")}
|
||||
{(status?.scopesEnforced ?? status?.heartbeat?.scopesEnforced)
|
||||
? t("yes")
|
||||
: t("no")}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
|
||||
@@ -37,7 +37,10 @@ export default function NotionSourceCard() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchConfig();
|
||||
// Async continuation — see react-hooks/set-state-in-effect.
|
||||
void (async () => {
|
||||
await fetchConfig();
|
||||
})();
|
||||
}, [fetchConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -50,8 +50,11 @@ export default function ObsidianSourceCard() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchConfig();
|
||||
void fetchWebdavStatus();
|
||||
// Async continuation — see react-hooks/set-state-in-effect.
|
||||
void (async () => {
|
||||
await fetchConfig();
|
||||
await fetchWebdavStatus();
|
||||
})();
|
||||
}, [fetchConfig, fetchWebdavStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -47,6 +47,25 @@ type SortKey =
|
||||
| "avgGapAfterToolMs";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
// Hoisted out of the page component so a stable component type survives re-renders
|
||||
// (react-hooks/static-components).
|
||||
function SortIcon({
|
||||
column,
|
||||
sortKey,
|
||||
sortDir,
|
||||
}: {
|
||||
column: SortKey;
|
||||
sortKey: SortKey;
|
||||
sortDir: SortDir;
|
||||
}) {
|
||||
if (sortKey !== column) return null;
|
||||
return (
|
||||
<span className="material-symbols-outlined text-[14px] ml-1 align-middle text-primary">
|
||||
{sortDir === "desc" ? "arrow_downward" : "arrow_upward"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(n: number | null): string {
|
||||
if (n == null) return "0";
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
@@ -95,7 +114,10 @@ export default function ProviderStatsPage() {
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
// Async continuation — see react-hooks/set-state-in-effect.
|
||||
void (async () => {
|
||||
await fetchData();
|
||||
})();
|
||||
const interval = setInterval(fetchData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
@@ -140,16 +162,6 @@ export default function ProviderStatsPage() {
|
||||
modelsByProvider.get(m.provider)!.push(m);
|
||||
}
|
||||
|
||||
// Sort helper icon
|
||||
const SortIcon = ({ column }: { column: SortKey }) => {
|
||||
if (sortKey !== column) return null;
|
||||
return (
|
||||
<span className="material-symbols-outlined text-[14px] ml-1 align-middle text-primary">
|
||||
{sortDir === "desc" ? "arrow_downward" : "arrow_upward"}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (!data && !error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -262,44 +274,51 @@ export default function ProviderStatsPage() {
|
||||
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
|
||||
onClick={() => handleSort("totalRequests")}
|
||||
>
|
||||
{t("requests")} <SortIcon column="totalRequests" />
|
||||
{t("requests")}{" "}
|
||||
<SortIcon column="totalRequests" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
|
||||
onClick={() => handleSort("successfulRequests")}
|
||||
>
|
||||
{t("success")} <SortIcon column="successfulRequests" />
|
||||
{t("success")}{" "}
|
||||
<SortIcon column="successfulRequests" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th className="text-right py-2 px-3 text-text-muted font-medium">{t("rate")}</th>
|
||||
<th
|
||||
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
|
||||
onClick={() => handleSort("avgLatencyMs")}
|
||||
>
|
||||
{t("avgLatency")} <SortIcon column="avgLatencyMs" />
|
||||
{t("avgLatency")}{" "}
|
||||
<SortIcon column="avgLatencyMs" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
|
||||
onClick={() => handleSort("totalTokensIn")}
|
||||
>
|
||||
{t("tokensIn")} <SortIcon column="totalTokensIn" />
|
||||
{t("tokensIn")}{" "}
|
||||
<SortIcon column="totalTokensIn" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
|
||||
onClick={() => handleSort("totalTokensOut")}
|
||||
>
|
||||
{t("tokensOut")} <SortIcon column="totalTokensOut" />
|
||||
{t("tokensOut")}{" "}
|
||||
<SortIcon column="totalTokensOut" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
|
||||
onClick={() => handleSort("avgTtftAfterToolMs")}
|
||||
>
|
||||
{t("ttftAfterTool")} <SortIcon column="avgTtftAfterToolMs" />
|
||||
{t("ttftAfterTool")}{" "}
|
||||
<SortIcon column="avgTtftAfterToolMs" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
|
||||
onClick={() => handleSort("avgGapAfterToolMs")}
|
||||
>
|
||||
{t("gapAfterTool")} <SortIcon column="avgGapAfterToolMs" />
|
||||
{t("gapAfterTool")}{" "}
|
||||
<SortIcon column="avgGapAfterToolMs" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th className="py-2 px-3 w-8" />
|
||||
</tr>
|
||||
|
||||
@@ -121,10 +121,15 @@ test("PoolWizard.tsx: pre-fill uses editPoolExclusive ?? false (not setExclusive
|
||||
);
|
||||
// The literal `setExclusive(false)` must NOT appear in the edit-mode pre-fill block
|
||||
// (it may still appear in the close-reset block, which is correct)
|
||||
const editFillIdx = wizardSrc.indexOf("} else if (editPool)");
|
||||
assert.ok(editFillIdx >= 0, "Expected '} else if (editPool)' block in PoolWizard");
|
||||
const closingBrace = wizardSrc.indexOf("\n }", editFillIdx);
|
||||
const editFillBlock = wizardSrc.slice(editFillIdx, closingBrace > 0 ? closingBrace + 6 : editFillIdx + 1200);
|
||||
// #12146 batch 5: the pre-fill moved from the reset effect ("} else if (editPool)")
|
||||
// into useWizardOpenCloseAdjustment's render adjustment.
|
||||
const editFillIdx = wizardSrc.indexOf("if (changed && open && editPool)");
|
||||
assert.ok(editFillIdx >= 0, "Expected the editPool pre-fill adjustment block in PoolWizard");
|
||||
const closingBrace = wizardSrc.indexOf("\n }", editFillIdx);
|
||||
const editFillBlock = wizardSrc.slice(
|
||||
editFillIdx,
|
||||
closingBrace > 0 ? closingBrace + 4 : editFillIdx + 1200
|
||||
);
|
||||
assert.ok(
|
||||
!editFillBlock.includes("setExclusive(false)"),
|
||||
"setExclusive(false) must not appear in the editPool pre-fill block — must use editPoolExclusive"
|
||||
|
||||
@@ -35,10 +35,7 @@ const ptBrJson = JSON.parse(fs.readFileSync(PT_BR_JSON_PATH, "utf-8")) as Record
|
||||
// ── PoolWizardProps: editPool field ───────────────────────────────────────────
|
||||
|
||||
test("PoolWizard.tsx: declares editPool in PoolWizardProps", () => {
|
||||
assert.ok(
|
||||
wizardSrc.includes("editPool?"),
|
||||
"Expected optional editPool field in PoolWizardProps"
|
||||
);
|
||||
assert.ok(wizardSrc.includes("editPool?"), "Expected optional editPool field in PoolWizardProps");
|
||||
});
|
||||
|
||||
test("PoolWizard.tsx: imports QuotaPool type", () => {
|
||||
@@ -69,11 +66,10 @@ test("PoolWizard.tsx: submit handler branches on editPool", () => {
|
||||
wizardSrc.includes("editPool"),
|
||||
"Expected editPool to appear in PoolWizard source (branching in submit)"
|
||||
);
|
||||
// The branching condition inside handleFinish
|
||||
assert.ok(
|
||||
wizardSrc.includes("if (editPool)"),
|
||||
"Expected if (editPool) branch in handleFinish"
|
||||
);
|
||||
// The branching condition inside handleFinish (create path guards on !editPool;
|
||||
// the old "} else if (editPool)" literal lived in the reset effect that #12146
|
||||
// batch 5 turned into a render adjustment).
|
||||
assert.ok(wizardSrc.includes("if (!editPool)"), "Expected the editPool branch in handleFinish");
|
||||
});
|
||||
|
||||
// ── Pre-fill references ───────────────────────────────────────────────────────
|
||||
@@ -112,17 +108,17 @@ test("PoolWizard.tsx: pre-fills groupId from editPool.groupId", () => {
|
||||
|
||||
// ── i18n key usage ────────────────────────────────────────────────────────────
|
||||
|
||||
test("PoolWizard.tsx: uses t(\"saveChanges\") for the submit button in edit mode", () => {
|
||||
test('PoolWizard.tsx: uses t("saveChanges") for the submit button in edit mode', () => {
|
||||
assert.ok(
|
||||
wizardSrc.includes('t("saveChanges")'),
|
||||
"Expected t(\"saveChanges\") used in submit button (edit mode)"
|
||||
'Expected t("saveChanges") used in submit button (edit mode)'
|
||||
);
|
||||
});
|
||||
|
||||
test("PoolWizard.tsx: uses t(\"editPoolTitle\") for the modal title in edit mode", () => {
|
||||
test('PoolWizard.tsx: uses t("editPoolTitle") for the modal title in edit mode', () => {
|
||||
assert.ok(
|
||||
wizardSrc.includes('t("editPoolTitle")'),
|
||||
"Expected t(\"editPoolTitle\") used in modal title (edit mode)"
|
||||
'Expected t("editPoolTitle") used in modal title (edit mode)'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user