mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-01 12:22:24 +03:00
Compare commits
1 Commits
docs/opena
...
chore/esli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f72493751b |
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -64,7 +64,9 @@ export default function A2aAuditTab() {
|
||||
}, [offset, skillFilter, stateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchTasks();
|
||||
void (async () => {
|
||||
await fetchTasks();
|
||||
})();
|
||||
}, [fetchTasks]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -63,7 +63,9 @@ export default function CacheEntriesTab() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEntries();
|
||||
void (async () => {
|
||||
await fetchEntries();
|
||||
})();
|
||||
}, [fetchEntries]);
|
||||
|
||||
const handleDelete = async (signature: string) => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
4
src/app/(dashboard)/dashboard/cache/page.tsx
vendored
4
src/app/(dashboard)/dashboard/cache/page.tsx
vendored
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -40,7 +40,9 @@ export function useMemorySettings(): UseMemorySettingsResult {
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void fetchOnce();
|
||||
void (async () => {
|
||||
await fetchOnce();
|
||||
})();
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
|
||||
@@ -95,7 +95,9 @@ export function RadarCatalogTable({ entries, refreshCatalog, onError }: RadarCat
|
||||
}, [onError, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadState();
|
||||
void (async () => {
|
||||
await loadState();
|
||||
})();
|
||||
}, [loadState]);
|
||||
|
||||
const stateByKey = useMemo(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -22,7 +22,9 @@ export default function RateLimitStatus() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
void (async () => {
|
||||
await load();
|
||||
})();
|
||||
const interval = setInterval(load, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [load]);
|
||||
|
||||
@@ -49,7 +49,9 @@ export default function SessionsTab() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
void (async () => {
|
||||
await loadSessions();
|
||||
})();
|
||||
const interval = setInterval(loadSessions, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadSessions]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user