Compare commits

...

1 Commits

Author SHA1 Message Date
diegosouzapw
f72493751b chore(lint): batch 6 of #12146 — memory, radar, audit, analytics, cache, usage, activity, home and RequestLoggerV2 react-hooks violations resolved
45 violations across 27 files fixed at the source (no eslint-disable, no new
suppressions; the 45 matching react-hooks/* entries are removed from
config/quality/eslint-suppressions.json):

- set-state-in-effect (fetch-on-mount effects): async continuation wrapper.
- Prop/state sync effects (EditMemoryModal, radar/setup, EvalsTab): adjust
  during render with prev tracking.
- purity/refs (ActivityFeedClient, ProviderQuotaWidget, ReasoningCacheTab):
  Date.now() snapshots moved to state set from the fetch path; rendered refs
  converted to state.
- immutability (useCodexResetCreditRedemption): ref-store writes extracted to
  module-level helpers.
- exhaustive-deps (RequestLoggerV2, HomePageClient): COLUMN_SORT_MAP hoisted to
  module scope; openDetail/closeDetail wrapped in useCallback and added to the
  dependent hooks; versionInfo destructured to locals; baseUrl now reads
  location.origin via useSyncExternalStore (hydration-safe, no effect).

Refs #12146
2026-08-31 13:35:09 -03:00
28 changed files with 321 additions and 361 deletions

View File

@@ -829,12 +829,6 @@
"src/app/(dashboard)/dashboard/HomePageClient.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 2
},
"react-hooks/exhaustive-deps": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/a2a/page.tsx": {
@@ -850,62 +844,16 @@
"count": 1
}
},
"src/app/(dashboard)/dashboard/activity/ActivityFeedClient.tsx": {
"react-hooks/purity": {
"count": 1
},
"react-hooks/refs": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/analytics/CacheHealthTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/analytics/ProviderUtilizationTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/analytics/RouteExplainabilityTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/audit/ComplianceTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/audit/McpAuditTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/batch/components/wizard/CostEstimateStep.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
@@ -926,24 +874,6 @@
"count": 1
}
},
"src/app/(dashboard)/dashboard/cache/components/CacheEntriesTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/cache/components/ReasoningCacheTab.tsx": {
"react-hooks/purity": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/cache/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/cli-agents/CliAgentsPageClient.tsx": {
"no-restricted-syntax": {
"count": 4
@@ -1060,31 +990,6 @@
"count": 2
}
},
"src/app/(dashboard)/dashboard/memory/components/EditMemoryModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/memory/hooks/useEngineStatus.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/memory/hooks/useMemorySettings.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/onboarding/page.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -1185,29 +1090,6 @@
"count": 1
}
},
"src/app/(dashboard)/dashboard/radar/RadarCatalogTable.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/radar/intel/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/radar/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/radar/setup/page.tsx": {
"react-hooks/preserve-manual-memoization": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/relay/RelayProxyClient.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
@@ -1376,11 +1258,6 @@
"count": 1
}
},
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 3
@@ -1391,11 +1268,6 @@
"count": 1
}
},
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/useCodexResetCreditRedemption.ts": {
"react-hooks/immutability": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -1404,17 +1276,11 @@
"src/app/(dashboard)/dashboard/usage/components/RateLimitStatus.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/usage/components/SessionsTab.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/webhooks/WebhooksPageClient.tsx": {
@@ -1437,17 +1303,6 @@
"count": 1
}
},
"src/app/(dashboard)/home/ProviderQuotaWidget.tsx": {
"react-hooks/purity": {
"count": 1
},
"react-hooks/refs": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/api/assess/route.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -2298,9 +2153,6 @@
"src/shared/components/RequestLoggerV2.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 3
},
"react-hooks/exhaustive-deps": {
"count": 6
}
},
"src/shared/components/RequestTimeline.tsx": {

View File

@@ -2,7 +2,7 @@
import { useTranslations } from "next-intl";
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { useState, useEffect, useMemo, useCallback, useRef, useSyncExternalStore } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Card, CardSkeleton, Button, Modal } from "@/shared/components";
@@ -106,6 +106,12 @@ const INLINE_LINK = "text-primary hover:underline";
const DOCS_LINK =
"hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors";
// Stable no-op subscription for useSyncExternalStore reads of never-changing
// browser globals (location.origin does not change without a full navigation).
function emptySubscribe() {
return () => {};
}
export default function HomePageClient({ machineId }: HomePageClientProps) {
const router = useRouter();
const isElectron = useIsElectron();
@@ -115,7 +121,13 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
const [providerConnections, setProviderConnections] = useState([]);
const [models, setModels] = useState([]);
const [loading, setLoading] = useState(true);
const [baseUrl, setBaseUrl] = useState("/v1");
// useSyncExternalStore keeps SSR/hydration consistent ("/v1" on the server,
// the real origin after hydration) without a setState-in-effect round-trip.
const baseUrl = useSyncExternalStore(
emptySubscribe,
() => `${globalThis.location.origin}/v1`,
() => "/v1"
);
const [selectedProvider, setSelectedProvider] = useState(null);
const [providerMetrics, setProviderMetrics] = useState<Record<string, ProviderMetricSummary>>({});
const [providerTopology, setProviderTopology] = useState({ lastProvider: "", errorProvider: "" });
@@ -135,36 +147,39 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
// Platform detection and download links for Electron
const platform =
typeof globalThis.window === "undefined" ? undefined : globalThis.window.electronAPI?.platform;
// Destructured to locals: `versionInfo?.current` in a dependency array trips
// the lint heuristic that treats any `.current` access as a mutable ref read.
const installedVersion = versionInfo?.current || "";
const latestVersion = versionInfo?.latest || "";
const electronDownload = useMemo(() => {
const latest = versionInfo?.latest || "";
const cleanLatest = latest.replace(/^v/, "");
const cleanLatest = latestVersion.replace(/^v/, "");
if (platform === "darwin") {
return {
label: t("downloadDmg"),
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.dmg`,
desc: t("downloadDmgDescription", { version: versionInfo?.current || "" }),
desc: t("downloadDmgDescription", { version: installedVersion }),
};
}
if (platform === "win32") {
return {
label: t("downloadExe"),
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute.Setup.${cleanLatest}.exe`,
desc: t("downloadExeDescription", { version: versionInfo?.current || "" }),
desc: t("downloadExeDescription", { version: installedVersion }),
};
}
if (platform === "linux") {
return {
label: t("downloadAppImage"),
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.AppImage`,
desc: t("downloadAppImageDescription", { version: versionInfo?.current || "" }),
desc: t("downloadAppImageDescription", { version: installedVersion }),
};
}
return {
label: t("downloadUpdate"),
url: `https://github.com/diegosouzapw/OmniRoute/releases/tag/v${cleanLatest}`,
desc: t("downloadUpdateDescription", { version: versionInfo?.current || "" }),
desc: t("downloadUpdateDescription", { version: installedVersion }),
};
}, [platform, t, versionInfo?.latest, versionInfo?.current]);
}, [platform, t, latestVersion, installedVersion]);
// Electron internal auto-updater state and listeners
const [electronUpdateStatus, setElectronUpdateStatus] = useState<{
@@ -234,12 +249,6 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
});
}, []);
useEffect(() => {
if (typeof globalThis.window !== "undefined") {
setBaseUrl(`${globalThis.location.origin}/v1`);
}
}, []);
const fetchData = useCallback(async () => {
try {
const [provRes, modelsRes, versionRes] = await Promise.all([
@@ -267,7 +276,9 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
}, []);
useEffect(() => {
fetchData();
void (async () => {
await fetchData();
})();
}, [fetchData]);
// Fetch provider nodes for display labels (compat providers)

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import type { AuditLogEntry } from "@/lib/compliance/index";
import ActivityFeed from "./components/ActivityFeed";
@@ -14,7 +14,9 @@ export default function ActivityFeedClient() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [category, setCategory] = useState<EventCategory>("all");
const referenceNowMs = useRef<number>(Date.now());
// State (not a ref) because it is rendered: refs cannot be read during
// render, and Date.now() cannot run there either — the fetch settles it.
const [referenceNowMs, setReferenceNowMs] = useState<number>(0);
const fetchEntries = useCallback(async () => {
setLoading(true);
@@ -30,7 +32,7 @@ export default function ActivityFeedClient() {
}
const data = (await res.json()) as AuditLogEntry[];
// Reset reference time on fresh load so relative timestamps are stable
referenceNowMs.current = Date.now();
setReferenceNowMs(Date.now());
setAllEntries(Array.isArray(data) ? data : []);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : t("fetchFailed");
@@ -41,7 +43,9 @@ export default function ActivityFeedClient() {
}, [t]);
useEffect(() => {
fetchEntries();
void (async () => {
await fetchEntries();
})();
}, [fetchEntries]);
const filtered =
@@ -114,7 +118,7 @@ export default function ActivityFeedClient() {
<span className="text-sm">{t("loadingActivity")}</span>
</div>
) : (
<ActivityFeed entries={filtered} referenceNowMs={referenceNowMs.current} />
<ActivityFeed entries={filtered} referenceNowMs={referenceNowMs} />
)}
</div>
</div>

View File

@@ -95,7 +95,9 @@ export default function CacheHealthTab() {
}, []);
useEffect(() => {
void load(range);
void (async () => {
await load(range);
})();
}, [load, range]);
if (loading) return <Skeleton className="h-64 w-full" />;
@@ -179,8 +181,8 @@ export default function CacheHealthTab() {
{text(t, "cacheHealthConcentration", "Where the writes are concentrated")}
</h3>
<span className="text-xs text-text-muted">
{text(t, "cacheHealthThreshold", "outlier above")} {compact(data.heavyWriteThreshold)}{" "}
{text(t, "cacheHealthTokens", "tokens")}
{text(t, "cacheHealthThreshold", "outlier above")}{" "}
{compact(data.heavyWriteThreshold)} {text(t, "cacheHealthTokens", "tokens")}
</span>
</div>
<p className="text-sm text-text-main">
@@ -216,7 +218,9 @@ export default function CacheHealthTab() {
<table className="w-full min-w-[560px] text-sm">
<thead>
<tr className="border-b border-border text-left text-xs uppercase text-text-muted">
<th className="pb-2 pr-4 font-medium">{text(t, "cacheHealthModel", "Model")}</th>
<th className="pb-2 pr-4 font-medium">
{text(t, "cacheHealthModel", "Model")}
</th>
<th className="pb-2 pr-4 text-right font-medium">
{text(t, "cacheHealthCalls", "Calls")}
</th>

View File

@@ -852,7 +852,9 @@ export default function ComboHealthTab() {
useEffect(() => {
const controller = new AbortController();
fetchData(controller, false);
void (async () => {
await fetchData(controller, false);
})();
return () => controller.abort();
}, [fetchData]);

View File

@@ -131,7 +131,9 @@ export default function ProviderUtilizationTab() {
useEffect(() => {
const controller = new AbortController();
fetchUtilization(range, aggregateBy, controller.signal);
void (async () => {
await fetchUtilization(range, aggregateBy, controller.signal);
})();
return () => controller.abort();
}, [fetchUtilization, range, aggregateBy]);
@@ -340,12 +342,8 @@ export default function ProviderUtilizationTab() {
<ProviderIcon providerId={providerPart} size={22} />
</div>
<div>
<p className="text-sm font-semibold text-text-main">
{cardTitle}
</p>
<p className="text-xs text-text-muted">
{cardSubtitle}
</p>
<p className="text-sm font-semibold text-text-main">{cardTitle}</p>
<p className="text-xs text-text-muted">{cardSubtitle}</p>
</div>
</div>
<span

View File

@@ -522,14 +522,18 @@ export default function RouteExplainabilityTab({
useEffect(() => {
const controller = new AbortController();
fetchLogs(controller.signal);
void (async () => {
await fetchLogs(controller.signal);
})();
return () => controller.abort();
}, [fetchLogs]);
useEffect(() => {
if (!selectedId) return;
const controller = new AbortController();
fetchExplanation(selectedId, controller.signal);
void (async () => {
await fetchExplanation(selectedId, controller.signal);
})();
return () => controller.abort();
}, [fetchExplanation, selectedId]);

View File

@@ -64,7 +64,9 @@ export default function A2aAuditTab() {
}, [offset, skillFilter, stateFilter]);
useEffect(() => {
void fetchTasks();
void (async () => {
await fetchTasks();
})();
}, [fetchTasks]);
return (

View File

@@ -110,7 +110,9 @@ export default function ComplianceTab() {
}, [actor, eventType, from, offset, t, to]);
useEffect(() => {
void fetchEntries();
void (async () => {
await fetchEntries();
})();
}, [fetchEntries]);
const visibleEntries = useMemo(() => {
@@ -330,7 +332,9 @@ export default function ComplianceTab() {
</td>
<td className="px-4 py-3">
<span className="rounded-md border border-border bg-surface px-2 py-1 font-mono text-xs text-text-main">
{t.has(`eventTypes.${entry.action}`) ? t(`eventTypes.${entry.action}`) : entry.action}
{t.has(`eventTypes.${entry.action}`)
? t(`eventTypes.${entry.action}`)
: entry.action}
</span>
</td>
<td className="px-4 py-3">

View File

@@ -56,7 +56,9 @@ export default function McpAuditTab() {
}, []);
useEffect(() => {
void fetchStats();
void (async () => {
await fetchStats();
})();
}, [fetchStats]);
const fetchAudit = useCallback(async () => {
@@ -86,7 +88,9 @@ export default function McpAuditTab() {
}, [offset, successFilter, t, toolFilter]);
useEffect(() => {
void fetchAudit();
void (async () => {
await fetchAudit();
})();
}, [fetchAudit]);
return (

View File

@@ -63,7 +63,9 @@ export default function CacheEntriesTab() {
);
useEffect(() => {
fetchEntries();
void (async () => {
await fetchEntries();
})();
}, [fetchEntries]);
const handleDelete = async (signature: string) => {

View File

@@ -130,9 +130,12 @@ export default function ReasoningCacheTab() {
const [loading, setLoading] = useState(true);
const [clearing, setClearing] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(null);
// Snapshot of "now" taken when the data lands (never during render — the
// purity rule bars Date.now() there); entries only render after a fetch.
const [nowMs, setNowMs] = useState(0);
const timeAgo = (dateStr: string): string => {
const diff = Date.now() - new Date(dateStr).getTime();
const diff = nowMs - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return t("justNow");
if (minutes < 60) return t("minutesAgo", { minutes });
@@ -147,6 +150,7 @@ export default function ReasoningCacheTab() {
const res = await fetch("/api/cache/reasoning");
if (res.ok) {
const json: ReasoningCacheData = await res.json();
setNowMs(Date.now());
setData(json);
}
} catch (error) {
@@ -157,7 +161,9 @@ export default function ReasoningCacheTab() {
}, []);
useEffect(() => {
void fetchData();
void (async () => {
await fetchData();
})();
const id = setInterval(() => void fetchData(), REFRESH_INTERVAL_MS);
return () => clearInterval(id);
}, [fetchData]);

View File

@@ -374,7 +374,9 @@ export default function CachePage() {
}, []);
useEffect(() => {
void fetchStats();
void (async () => {
await fetchStats();
})();
const id = setInterval(() => void fetchStats(), REFRESH_INTERVAL_MS);
return () => clearInterval(id);
}, [fetchStats]);

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import { Modal, Button, Input, Select } from "@/shared/components";
import { useTranslations } from "next-intl";
@@ -29,7 +29,14 @@ export default function EditMemoryModal({ memory, isOpen, onClose, onSaved }: Pr
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
// Adjust-during-render (React docs pattern): when the modal (re)opens for a
// memory, seed the form fields from it before painting — no effect round-trip.
const [prevSync, setPrevSync] = useState<{ memory: Memory | null; isOpen: boolean }>({
memory: null,
isOpen: false,
});
if (memory !== prevSync.memory || isOpen !== prevSync.isOpen) {
setPrevSync({ memory, isOpen });
if (memory && isOpen) {
setType(memory.type);
setKey(memory.key);
@@ -38,7 +45,7 @@ export default function EditMemoryModal({ memory, isOpen, onClose, onSaved }: Pr
setMetadataError("");
setError("");
}
}, [memory, isOpen]);
}
const handleMetadataChange = (value: string) => {
setMetadataStr(value);
@@ -151,9 +158,7 @@ export default function EditMemoryModal({ memory, isOpen, onClose, onSaved }: Pr
metadataError ? "border-red-500" : "border-border"
}`}
/>
{metadataError && (
<p className="text-xs text-red-400 mt-1">{metadataError}</p>
)}
{metadataError && <p className="text-xs text-red-400 mt-1">{metadataError}</p>}
</div>
</div>
</Modal>

View File

@@ -40,7 +40,8 @@ export default function QdrantConfigCard() {
collection?: { exists: boolean; vectorSize?: number; vectorName?: string | null };
} | null>(null);
const [searchValidated, setSearchValidated] = useState(false);
const [tutorialOpen, setTutorialOpen] = useState(false); const [checking, setChecking] = useState(false);
const [tutorialOpen, setTutorialOpen] = useState(false);
const [checking, setChecking] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searching, setSearching] = useState(false);
const [searchResults, setSearchResults] = useState<
@@ -109,7 +110,8 @@ export default function QdrantConfigCard() {
// invalidate in-flight checks so they cannot overwrite the new state.
healthSeqRef.current += 1;
setHealth(null);
setSearchValidated(false); setQdrant(next);
setSearchValidated(false);
setQdrant(next);
setSaving(true);
setSaveStatus("");
try {
@@ -153,7 +155,8 @@ export default function QdrantConfigCard() {
setSaving(false);
}
},
[qdrant, checkHealth] );
[qdrant, checkHealth]
);
// Auto-check on mount once settings load: without this the status badge
// renders red after a page refresh because `health` starts as null and the
@@ -161,7 +164,9 @@ export default function QdrantConfigCard() {
// connection button still drives the same check manually.
useEffect(() => {
if (!loading && qdrant.enabled && health === null) {
void checkHealth();
void (async () => {
await checkHealth();
})();
}
}, [loading, qdrant.enabled, health, checkHealth]);
@@ -245,7 +250,8 @@ export default function QdrantConfigCard() {
? "text-text-muted"
: health.ok
? "text-emerald-500"
: "text-red-500" }`}
: "text-red-500"
}`}
>
<span
className={`inline-block w-2.5 h-2.5 rounded-full ${

View File

@@ -190,9 +190,7 @@ export default function MemoriesTab() {
else skipped++;
}
fetchMemories();
setImportStatus(
t("importResult", { imported, skipped }),
);
setImportStatus(t("importResult", { imported, skipped }));
} catch {
setImportStatus(t("importError"));
} finally {
@@ -239,7 +237,9 @@ export default function MemoriesTab() {
// Auto-run health check on mount + poll every 30s, so the indicator reflects
// engine health without requiring a manual click.
useEffect(() => {
void checkHealth();
void (async () => {
await checkHealth();
})();
const id = setInterval(() => {
void checkHealth();
}, 30_000);
@@ -260,8 +260,9 @@ export default function MemoriesTab() {
body: JSON.stringify({ dryRun: true, olderThanDays: 30 }),
});
const data = await res.json().catch(() => null);
const candidates: string[] =
Array.isArray(data?.candidates) ? data.candidates.map((c: { key?: string }) => c?.key ?? String(c)) : [];
const candidates: string[] = Array.isArray(data?.candidates)
? data.candidates.map((c: { key?: string }) => c?.key ?? String(c))
: [];
setSummarizeCandidates(candidates);
setSummarizeDialogOpen(true);
} catch {
@@ -289,8 +290,7 @@ export default function MemoriesTab() {
}
};
const showHitRate =
(stats.cacheStats?.hits ?? 0) + (stats.cacheStats?.misses ?? 0) > 0;
const showHitRate = (stats.cacheStats?.hits ?? 0) + (stats.cacheStats?.misses ?? 0) > 0;
if (isLoading) {
return (
@@ -401,9 +401,7 @@ export default function MemoriesTab() {
info
</span>
</div>
<div className="text-2xl font-bold">
{((stats.hitRate ?? 0) * 100).toFixed(1)}%
</div>
<div className="text-2xl font-bold">{((stats.hitRate ?? 0) * 100).toFixed(1)}%</div>
</div>
</Card>
)}
@@ -448,12 +446,8 @@ export default function MemoriesTab() {
<span className="material-symbols-outlined text-[40px] text-text-muted mb-3">
psychology
</span>
<p className="text-sm font-medium text-text-main mb-1">
{t("emptyState.title")}
</p>
<p className="text-xs text-text-muted max-w-xs">
{t("emptyState.description")}
</p>
<p className="text-sm font-medium text-text-main mb-1">{t("emptyState.title")}</p>
<p className="text-xs text-text-muted max-w-xs">{t("emptyState.description")}</p>
<Button className="mt-4" size="sm" onClick={() => setAddDialogOpen(true)}>
{t("addMemory")}
</Button>
@@ -477,7 +471,9 @@ export default function MemoriesTab() {
<td className="py-2 px-4">
<Badge
variant={getTypeColor(memory.type)}
title={t(TYPE_TOOLTIPS[memory.type]?.replace("memory.", "") ?? memory.type)}
title={t(
TYPE_TOOLTIPS[memory.type]?.replace("memory.", "") ?? memory.type
)}
>
{t(memory.type)}
</Badge>
@@ -665,7 +661,10 @@ export default function MemoriesTab() {
</p>
<ul className="space-y-1 max-h-48 overflow-y-auto">
{summarizeCandidates.map((key, i) => (
<li key={i} className="text-xs font-mono text-text-main truncate px-2 py-1 bg-surface/30 rounded">
<li
key={i}
className="text-xs font-mono text-text-main truncate px-2 py-1 bg-surface/30 rounded"
>
{key}
</li>
))}

View File

@@ -39,7 +39,9 @@ export function useEngineStatus(refreshIntervalMs = 5000): UseEngineStatusResult
useEffect(() => {
mounted.current = true;
void fetchOnce();
void (async () => {
await fetchOnce();
})();
if (!refreshIntervalMs || refreshIntervalMs <= 0) {
return () => {
mounted.current = false;

View File

@@ -40,7 +40,9 @@ export function useMemorySettings(): UseMemorySettingsResult {
useEffect(() => {
mounted.current = true;
void fetchOnce();
void (async () => {
await fetchOnce();
})();
return () => {
mounted.current = false;
};

View File

@@ -95,7 +95,9 @@ export function RadarCatalogTable({ entries, refreshCatalog, onError }: RadarCat
}, [onError, t]);
useEffect(() => {
void loadState();
void (async () => {
await loadState();
})();
}, [loadState]);
const stateByKey = useMemo(

View File

@@ -67,9 +67,15 @@ export default function RadarIntelPage() {
}, [load, t]);
useEffect(() => {
load()
.catch(() => setError(t("loadFailed")))
.finally(() => setLoading(false));
void (async () => {
try {
await load();
} catch {
setError(t("loadFailed"));
} finally {
setLoading(false);
}
})();
}, [load, t]);
if (flagOff) notFound();

View File

@@ -201,7 +201,9 @@ export default function RadarPage() {
}, [fetchCatalog, fetchReferrals]);
useEffect(() => {
fetchSettings();
void (async () => {
await fetchSettings();
})();
}, [fetchSettings]);
// Sync (defined before handleActivate which depends on it)
@@ -242,7 +244,9 @@ export default function RadarPage() {
if (loading || syncing || optIn !== true || autoSyncFiredRef.current) return;
if (!shouldAutoSyncOnOpen(meta?.fetchedAt ?? null, Date.now())) return;
autoSyncFiredRef.current = true;
void handleSync();
void (async () => {
await handleSync();
})();
}, [loading, syncing, optIn, meta, handleSync]);
// Activate opt-in

View File

@@ -55,7 +55,16 @@ export default function RadarSetupPage() {
const provider = searchParams.get("provider");
const [setupData, setSetupData] = useState<ProviderSetupData | null>(null);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(provider !== null);
// Adjust-during-render when the provider query param changes (React docs
// pattern): a null provider has nothing to load, any other transition
// restarts the loading state before the fetch effect fires.
const [prevProvider, setPrevProvider] = useState(provider);
if (provider !== prevProvider) {
setPrevProvider(provider);
setLoading(provider !== null);
}
const [error, setError] = useState("");
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
@@ -63,7 +72,6 @@ export default function RadarSetupPage() {
// Fetch catalog to find the provider's setup data
useEffect(() => {
if (!provider) {
setLoading(false);
return;
}
@@ -123,12 +131,13 @@ export default function RadarSetupPage() {
}, [provider, t]);
// Test connection — uses the EXISTING connection-test endpoint
const connectionId = setupData?.connectionId ?? null;
const handleTestConnection = useCallback(async () => {
if (!setupData?.connectionId) return;
if (!connectionId) return;
setTesting(true);
setTestResult(null);
try {
const res = await fetch(`/api/providers/${encodeURIComponent(setupData.connectionId)}/test`, {
const res = await fetch(`/api/providers/${encodeURIComponent(connectionId)}/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
@@ -147,7 +156,7 @@ export default function RadarSetupPage() {
} finally {
setTesting(false);
}
}, [setupData?.connectionId, t]);
}, [connectionId, t]);
if (!provider) {
return (

View File

@@ -490,18 +490,18 @@ export default function EvalsTab() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (targetOptions.length === 0) return;
if (targetOptions.some((option) => option.key === selectedTargetKey)) return;
// Adjust-during-render (React docs pattern): keep the selected target inside
// the current option set, and never compare a target against itself. Both
// guards self-extinguish after their setState, so the re-render settles.
if (
targetOptions.length > 0 &&
!targetOptions.some((option) => option.key === selectedTargetKey)
) {
setSelectedTargetKey(targetOptions[0]?.key || "suite-default:__default__");
}, [selectedTargetKey, targetOptions]);
useEffect(() => {
if (!compareTargetKey) return;
if (compareTargetKey === selectedTargetKey) {
setCompareTargetKey("");
}
}, [compareTargetKey, selectedTargetKey]);
}
if (compareTargetKey && compareTargetKey === selectedTargetKey) {
setCompareTargetKey("");
}
const filteredSuites = !search.trim()
? suites
@@ -1846,7 +1846,11 @@ export default function EvalsTab() {
);
}
const HeroSection = memo(function HeroSection({ t }: { t: (key: string, values?: Record<string, unknown>) => string }) {
const HeroSection = memo(function HeroSection({
t,
}: {
t: (key: string, values?: Record<string, unknown>) => string;
}) {
return (
<Card className="p-0 overflow-hidden">
<div

View File

@@ -41,6 +41,23 @@ interface ResetCreditRequestState {
tr: TranslateUsage;
}
// Module-level so the ref-store mutation stays outside any hook body — the
// immutability rule bars in-callback writes to `state.idempotencyKeysRef.current`.
function resetIdempotencyKeys(keys: React.MutableRefObject<Record<string, string>>): void {
keys.current = {};
}
function ensureIdempotencyKey(
keys: React.MutableRefObject<Record<string, string>>,
selectionToken: string
): string {
const existing = keys.current[selectionToken];
if (existing) return existing;
const created = createIdempotencyKey();
keys.current[selectionToken] = created;
return created;
}
function createIdempotencyKey(): string {
return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
@@ -112,9 +129,7 @@ function useRedeemCodexResetCredit(state: ResetCreditRequestState) {
async (selectionToken: string) => {
const picker = state.resetCreditPicker;
if (!picker || state.redeemingResetCreditId || !selectionToken) return;
const idempotencyKey =
state.idempotencyKeysRef.current[selectionToken] ??
(state.idempotencyKeysRef.current[selectionToken] = createIdempotencyKey());
const idempotencyKey = ensureIdempotencyKey(state.idempotencyKeysRef, selectionToken);
state.setRedeemingResetCreditId(picker.connectionId);
state.setErrors((prev) => ({ ...prev, [picker.connectionId]: null }));
try {
@@ -145,7 +160,7 @@ function useRedeemCodexResetCredit(state: ResetCreditRequestState) {
[picker.connectionId]: new Date().toISOString(),
}));
state.setResetCreditPicker(null);
state.idempotencyKeysRef.current = {};
resetIdempotencyKeys(state.idempotencyKeysRef);
notify.success(state.tr("resetCreditRedeemed", "Reset redeemed"));
} catch (error) {
const message = getRequestErrorMessage(

View File

@@ -22,7 +22,9 @@ export default function RateLimitStatus() {
}, []);
useEffect(() => {
load();
void (async () => {
await load();
})();
const interval = setInterval(load, 10000);
return () => clearInterval(interval);
}, [load]);

View File

@@ -49,7 +49,9 @@ export default function SessionsTab() {
}, []);
useEffect(() => {
loadSessions();
void (async () => {
await loadSessions();
})();
const interval = setInterval(loadSessions, 5000);
return () => clearInterval(interval);
}, [loadSessions]);

View File

@@ -161,7 +161,9 @@ export default function ProviderQuotaWidget({
const [refreshingAll, setRefreshingAll] = useState(false);
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
const refreshingAllRef = useRef(false);
const lastRefreshAllAtRef = useRef(Date.now());
// State (not a ref): the countdown renders it, and refs cannot be read during
// render nor initialized with Date.now() (purity rule).
const [lastRefreshAllAt, setLastRefreshAllAt] = useState(() => Date.now());
const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0;
const [autoRefreshClock, setAutoRefreshClock] = useState(() => Date.now());
@@ -188,14 +190,16 @@ export default function ProviderQuotaWidget({
}, []);
useEffect(() => {
void loadData();
void (async () => {
await loadData();
})();
}, [loadData]);
const refreshAll = useCallback(async () => {
if (refreshingAllRef.current) return;
refreshingAllRef.current = true;
const now = Date.now();
lastRefreshAllAtRef.current = now;
setLastRefreshAllAt(now);
setAutoRefreshClock(now);
setRefreshingAll(true);
try {
@@ -235,10 +239,12 @@ export default function ProviderQuotaWidget({
if (document.visibilityState !== "visible") return;
if (refreshingAllRef.current) return;
if (autoRefreshClock - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) {
void refreshAll();
if (autoRefreshClock - lastRefreshAllAt >= autoRefreshIntervalMs) {
void (async () => {
await refreshAll();
})();
}
}, [autoRefreshClock, autoRefreshIntervalMs, refreshAll]);
}, [autoRefreshClock, lastRefreshAllAt, autoRefreshIntervalMs, refreshAll]);
const providerGroups = useMemo(() => {
const groups = new Map<string, Connection[]>();
@@ -286,10 +292,7 @@ export default function ProviderQuotaWidget({
? tr("refreshing", "Refreshing")
: autoRefreshIntervalMs > 0
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
Math.max(
0,
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
)
Math.max(0, autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAt))
)}`
: tr("forceRefresh", "Refresh now")}
</button>

View File

@@ -55,6 +55,16 @@ import {
// Reduced from 300 → 50 to avoid browser freeze and network saturation.
const PAGE_SIZE = 50;
// Column sort toggle mapping: clicking a column header toggles asc/desc.
const COLUMN_SORT_MAP = {
status: { desc: "status_desc", asc: "status_asc" },
model: { desc: "model_desc", asc: "model_asc" },
tokens: { desc: "tokens_desc", asc: "tokens_asc" },
tps: { desc: "tps_desc", asc: "tps_asc" },
duration: { desc: "duration_desc", asc: "duration_asc" },
time: { desc: "newest", asc: "oldest" },
} as const;
function getLogTotalTokens(log) {
return (log?.tokens?.in || 0) + (log?.tokens?.out || 0);
}
@@ -147,17 +157,8 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
const [groupedView, setGroupedView] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
// Column sort toggle: clicking a column header toggles asc/desc
const columnSortMap = {
status: { desc: "status_desc", asc: "status_asc" },
model: { desc: "model_desc", asc: "model_asc" },
tokens: { desc: "tokens_desc", asc: "tokens_asc" },
tps: { desc: "tps_desc", asc: "tps_asc" },
duration: { desc: "duration_desc", asc: "duration_asc" },
time: { desc: "newest", asc: "oldest" },
};
const toggleSort = useCallback((column: string) => {
const mapping = columnSortMap[column as keyof typeof columnSortMap];
const mapping = COLUMN_SORT_MAP[column as keyof typeof COLUMN_SORT_MAP];
if (!mapping) return;
setSortBy((prev) => {
if (prev === mapping.desc) return mapping.asc;
@@ -166,7 +167,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
}, []);
const getSortIndicator = useCallback(
(column: string) => {
const mapping = columnSortMap[column as keyof typeof columnSortMap];
const mapping = COLUMN_SORT_MAP[column as keyof typeof COLUMN_SORT_MAP];
if (!mapping) return "";
if (sortBy === mapping.desc) return " ↓";
if (sortBy === mapping.asc) return " ↑";
@@ -535,89 +536,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
// endpoint until the row appears.
const router = useRouter();
const openDetail = async (logEntry) => {
// Guard: if no valid id provided, close instead of opening an empty modal
if (!logEntry?.id) {
try {
closeDetail();
} catch {}
return;
}
const requestToken = `${logEntry.id}:${Date.now()}:${Math.random()}`;
detailRequestRef.current = requestToken;
const isCurrentDetailRequest = () => detailRequestRef.current === requestToken;
setSelectedLog(logEntry);
try {
const url = new URL(globalThis.location.href);
url.searchParams.set("id", logEntry.id);
router.replace(url.pathname + url.search, { scroll: false });
} catch (e) {
// ignore navigation errors
}
setDetailLoading(true);
setDetailData(null);
try {
const res = await fetch(`/api/logs/${logEntry.id}`, { cache: "no-store" });
if (res.ok) {
const data = await res.json();
if (!isCurrentDetailRequest()) return;
const dataHasPipeline =
data?.pipelinePayloads && Object.keys(data.pipelinePayloads || {}).length > 0;
setDetailData((prev: { pipelinePayloads: any }) => ({
...prev,
...data,
pipelinePayloads: dataHasPipeline ? data.pipelinePayloads : prev?.pipelinePayloads,
}));
// ensure the modal summary reflects the fetched call log summary
if (data && typeof data === "object") {
setSelectedLog((prev: any) => ({
...prev,
...data,
active: data.active === true,
}));
}
} else {
// A deep-linked id can legitimately 404 while the request is still
// finalizing. Keep the modal open and poll /api/logs/[id] instead of
// falling back to an in-memory active-request endpoint.
if (!isCurrentDetailRequest()) return;
if (res.status === 404) {
if (logEntry.pendingLookup || logEntry.active) {
setSelectedLog((prev: { method: any; path: any }) => ({
...prev,
id: logEntry.id,
status: 0,
method: prev?.method,
path: prev?.path || "",
}));
setDetailData({ detailState: "pending" });
return;
}
try {
console.warn("Log not found:", logEntry.id);
} catch {}
try {
closeDetail();
} catch {}
return;
}
// other errors: show a minimal error indicator by setting detailData to an error object
try {
const body = await res.text().catch(() => null);
if (!isCurrentDetailRequest()) return;
setDetailData({ error: `Failed to fetch log (status ${res.status})`, body });
} catch {}
}
} catch (error) {
console.error("Failed to fetch log detail:", error);
} finally {
if (isCurrentDetailRequest()) setDetailLoading(false);
}
};
const closeDetail = () => {
const closeDetail = useCallback(() => {
detailRequestRef.current = "";
setSelectedLog(null);
setDetailData(null);
@@ -629,7 +548,92 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
} catch (e) {
// ignore navigation errors
}
};
}, [router]);
const openDetail = useCallback(
async (logEntry) => {
// Guard: if no valid id provided, close instead of opening an empty modal
if (!logEntry?.id) {
try {
closeDetail();
} catch {}
return;
}
const requestToken = `${logEntry.id}:${Date.now()}:${Math.random()}`;
detailRequestRef.current = requestToken;
const isCurrentDetailRequest = () => detailRequestRef.current === requestToken;
setSelectedLog(logEntry);
try {
const url = new URL(globalThis.location.href);
url.searchParams.set("id", logEntry.id);
router.replace(url.pathname + url.search, { scroll: false });
} catch (e) {
// ignore navigation errors
}
setDetailLoading(true);
setDetailData(null);
try {
const res = await fetch(`/api/logs/${logEntry.id}`, { cache: "no-store" });
if (res.ok) {
const data = await res.json();
if (!isCurrentDetailRequest()) return;
const dataHasPipeline =
data?.pipelinePayloads && Object.keys(data.pipelinePayloads || {}).length > 0;
setDetailData((prev: { pipelinePayloads: any }) => ({
...prev,
...data,
pipelinePayloads: dataHasPipeline ? data.pipelinePayloads : prev?.pipelinePayloads,
}));
// ensure the modal summary reflects the fetched call log summary
if (data && typeof data === "object") {
setSelectedLog((prev: any) => ({
...prev,
...data,
active: data.active === true,
}));
}
} else {
// A deep-linked id can legitimately 404 while the request is still
// finalizing. Keep the modal open and poll /api/logs/[id] instead of
// falling back to an in-memory active-request endpoint.
if (!isCurrentDetailRequest()) return;
if (res.status === 404) {
if (logEntry.pendingLookup || logEntry.active) {
setSelectedLog((prev: { method: any; path: any }) => ({
...prev,
id: logEntry.id,
status: 0,
method: prev?.method,
path: prev?.path || "",
}));
setDetailData({ detailState: "pending" });
return;
}
try {
console.warn("Log not found:", logEntry.id);
} catch {}
try {
closeDetail();
} catch {}
return;
}
// other errors: show a minimal error indicator by setting detailData to an error object
try {
const body = await res.text().catch(() => null);
if (!isCurrentDetailRequest()) return;
setDetailData({ error: `Failed to fetch log (status ${res.status})`, body });
} catch {}
}
} catch (error) {
console.error("Failed to fetch log detail:", error);
} finally {
if (isCurrentDetailRequest()) setDetailLoading(false);
}
},
[closeDetail, router]
);
const sortedLogsForNav = useMemo(() => sortedLogs, [sortedLogs]);
@@ -654,7 +658,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
console.error("Failed to open initial log id:", error_);
});
}
}, [initialSelectedId]);
}, [initialSelectedId, openDetail]);
useEffect(() => {
const isActive = selectedLog?.active === true;
@@ -765,7 +769,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
pendingBoundaryNavRef.current = "prev";
fetchLogs(false);
}
}, [currentLogIndex, sortedLogsForNav, fetchLogs]);
}, [currentLogIndex, sortedLogsForNav, fetchLogs, openDetail]);
const handleNext = useCallback(() => {
const idx = currentLogIndex;
@@ -781,7 +785,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
pendingBoundaryNavRef.current = "next";
fetchLogs(false);
}
}, [currentLogIndex, sortedLogsForNav, fetchLogs]);
}, [currentLogIndex, sortedLogsForNav, fetchLogs, openDetail]);
// Resolves a pending boundary nav (see handlePrev/handleNext) once a
// triggered fetchLogs() resync has landed in sortedLogsForNav. Only fires