diff --git a/src/shared/components/ActiveRequestsPanel.tsx b/src/shared/components/ActiveRequestsPanel.tsx index 2466f26ac3..9e84069710 100644 --- a/src/shared/components/ActiveRequestsPanel.tsx +++ b/src/shared/components/ActiveRequestsPanel.tsx @@ -62,6 +62,8 @@ export default function ActiveRequestsPanel() { let cancelled = false; const load = async () => { + // Skip polling when tab is not visible to save resources + if (document.visibilityState !== "visible") return; try { const res = await fetch("/api/logs/active", { cache: "no-store" }); if (!res.ok) return; @@ -81,7 +83,7 @@ export default function ActiveRequestsPanel() { }; load(); - const interval = setInterval(load, 3000); + const interval = setInterval(load, 15_000); return () => { cancelled = true; clearInterval(interval); diff --git a/src/shared/components/RequestLoggerV2.tsx b/src/shared/components/RequestLoggerV2.tsx index f2cc14a1b4..1ca9d039e9 100644 --- a/src/shared/components/RequestLoggerV2.tsx +++ b/src/shared/components/RequestLoggerV2.tsx @@ -20,11 +20,20 @@ import { } from "@/shared/utils/formatting"; import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; +import { + computeLogsSignature, + resolveInitialVisibility, + shouldAutoRefresh, +} from "./requestLoggerSignature"; // Number of call-log rows fetched per page. The viewer grows its window by this // amount on "Load more" / infinite scroll so users can browse past the first // page (previously hardcoded to a single 300-row window). See #2565. -const PAGE_SIZE = 300; +// Reduced from 300 → 50 to avoid browser freeze and network saturation. +const PAGE_SIZE = 50; + +// Polling interval in ms. 15s balances freshness vs resource usage. +const POLL_INTERVAL_MS = 15_000; function getLogTotalTokens(log) { return (log?.tokens?.in || 0) + (log?.tokens?.out || 0); @@ -116,6 +125,7 @@ export default function RequestLoggerV2() { const scrollContainerRef = useRef(null); const loadMoreSentinelRef = useRef(null); const [providerNodes, setProviderNodes] = useState([]); + const visibleRef = useRef(resolveInitialVisibility()); // Column visibility with localStorage persistence const [visibleColumns, setVisibleColumns] = useState(() => { @@ -159,8 +169,10 @@ export default function RequestLoggerV2() { const data = await res.json(); // If the server returned a full window, more rows may exist beyond it. setHasMore(Array.isArray(data) && data.length >= limit); - // Skip re-render if data hasn't changed (#1369 GPU perf) - const sig = JSON.stringify(data.map?.((l: any) => l.id) ?? []); + // Skip re-render if data hasn't changed (#1369 GPU perf). The signature + // captures id + status + duration + tokens_out so in-progress updates + // still re-render while identical snapshots are skipped. + const sig = computeLogsSignature(data); if (sig !== logsSignatureRef.current) { logsSignatureRef.current = sig; setLogs(data); @@ -202,16 +214,31 @@ export default function RequestLoggerV2() { .catch(() => {}); }, []); - // Auto-refresh + // Visibility-aware auto-refresh: pause polling when tab is hidden or user has + // scrolled past the first page to avoid escalating payload sizes. + useEffect(() => { + const onVisibility = () => { + const isVisible = document.visibilityState === "visible"; + visibleRef.current = isVisible; + if (isVisible && shouldAutoRefresh(recording, limit, PAGE_SIZE)) { + fetchLogs(false); + } + }; + document.addEventListener("visibilitychange", onVisibility); + return () => document.removeEventListener("visibilitychange", onVisibility); + }, [recording, limit, fetchLogs]); + useEffect(() => { if (intervalRef.current) clearInterval(intervalRef.current); - if (recording) { - intervalRef.current = setInterval(() => fetchLogs(false), 3000); + if (shouldAutoRefresh(recording, limit, PAGE_SIZE)) { + intervalRef.current = setInterval(() => { + if (visibleRef.current) fetchLogs(false); + }, POLL_INTERVAL_MS); } return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; - }, [recording, fetchLogs]); + }, [recording, fetchLogs, limit]); // Reset the window back to the first page whenever the active filters change, // so switching filters doesn't keep fetching a large expanded window. @@ -323,23 +350,37 @@ export default function RequestLoggerV2() { // Unique accounts and providers for dropdowns - const uniqueAccounts = [...new Set(logs.map((l) => l.account).filter((a) => a && a !== "-"))]; - const uniqueModels = [ - ...new Set(logs.flatMap((l) => [l.model, l.requestedModel]).filter((value) => Boolean(value))), - ].sort(); - const uniqueProviders = [ - ...new Set(logs.map((l) => l.provider).filter((p) => p && p !== "-")), - ].sort(); - const uniqueApiKeys = [ - ...new Set(logs.map((l) => l.apiKeyId || l.apiKeyName).filter(Boolean)), - ].sort(); + const uniqueAccounts = useMemo( + () => [...new Set(logs.map((l) => l.account).filter((a) => a && a !== "-"))], + [logs] + ); + const uniqueModels = useMemo( + () => [ + ...new Set(logs.flatMap((l) => [l.model, l.requestedModel]).filter((value) => Boolean(value))), + ].sort(), + [logs] + ); + const uniqueProviders = useMemo( + () => [ + ...new Set(logs.map((l) => l.provider).filter((p) => p && p !== "-")), + ].sort(), + [logs] + ); + const uniqueApiKeys = useMemo( + () => [ + ...new Set(logs.map((l) => l.apiKeyId || l.apiKeyName).filter(Boolean)), + ].sort(), + [logs] + ); - // Stats - const totalCount = filteredLogs.length; - const okCount = filteredLogs.filter((l) => l.status >= 200 && l.status < 300).length; - const errorCount = filteredLogs.filter((l) => l.status >= 400).length; - const comboCount = logs.filter((l) => l.comboName).length; - const apiKeyCount = uniqueApiKeys.length; + // Stats (memoized to avoid re-computation on every render) + const { totalCount, okCount, errorCount, comboCount, apiKeyCount } = useMemo(() => ({ + totalCount: filteredLogs.length, + okCount: filteredLogs.filter((l) => l.status >= 200 && l.status < 300).length, + errorCount: filteredLogs.filter((l) => l.status >= 400).length, + comboCount: logs.filter((l) => l.comboName).length, + apiKeyCount: uniqueApiKeys.length, + }), [filteredLogs, logs, uniqueApiKeys]); return (
diff --git a/src/shared/components/requestLoggerSignature.ts b/src/shared/components/requestLoggerSignature.ts new file mode 100644 index 0000000000..1ea8561be1 --- /dev/null +++ b/src/shared/components/requestLoggerSignature.ts @@ -0,0 +1,38 @@ +/** + * Pure helpers extracted from RequestLoggerV2 so the polling + change-detection + * logic introduced in #3109 (browser-freeze / network-saturation fix) is unit + * testable without rendering the full dashboard component. + */ + +/** + * Initial tab-visibility for the polling guard. SSR-safe: when `document` is + * unavailable (server render) we assume visible so the first client poll runs. + * A component mounted in an already-hidden tab must NOT poll until it becomes + * visible — hence we read the real `visibilityState` when it exists. + */ +export function resolveInitialVisibility(): boolean { + return typeof document === "undefined" || document.visibilityState === "visible"; +} + +/** + * Whether auto-refresh polling should be scheduled at all. We only poll while + * recording AND the user is still on the first page (`limit <= pageSize`); once + * they "load more" / scroll into history, polling pauses so the payload size + * does not escalate (the original browser-freeze cause). + */ +export function shouldAutoRefresh(recording: boolean, limit: number, pageSize: number): boolean { + return recording && limit <= pageSize; +} + +/** + * Change-detection signature over the log rows. Captures id + status + duration + * + tokens_out so in-progress updates (a request finishing, duration/token + * growth) still trigger a re-render, while an identical snapshot is skipped + * (#1369 GPU perf). Non-array input collapses to an empty signature. + */ +export function computeLogsSignature(data: unknown): string { + const arr = Array.isArray(data) ? data : []; + return arr + .map((l: any) => l.id + ":" + l.status + ":" + l.duration + ":" + (l.tokens?.out || 0)) + .join("|"); +} diff --git a/tests/unit/request-logger-signature.test.ts b/tests/unit/request-logger-signature.test.ts new file mode 100644 index 0000000000..ebf5b62199 --- /dev/null +++ b/tests/unit/request-logger-signature.test.ts @@ -0,0 +1,79 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + computeLogsSignature, + resolveInitialVisibility, + shouldAutoRefresh, +} from "../../src/shared/components/requestLoggerSignature.ts"; + +const PAGE_SIZE = 50; + +test("shouldAutoRefresh: polls only while recording AND on the first page", () => { + assert.equal(shouldAutoRefresh(true, PAGE_SIZE, PAGE_SIZE), true); // exactly first page + assert.equal(shouldAutoRefresh(true, 10, PAGE_SIZE), true); + assert.equal(shouldAutoRefresh(false, 10, PAGE_SIZE), false); // not recording + assert.equal(shouldAutoRefresh(true, PAGE_SIZE + 1, PAGE_SIZE), false); // scrolled past page 1 + assert.equal(shouldAutoRefresh(true, 500, PAGE_SIZE), false); // deep history window +}); + +test("computeLogsSignature: stable across identical snapshots", () => { + const snapshot = [ + { id: "a", status: 200, duration: 12, tokens: { out: 5 } }, + { id: "b", status: 200, duration: 30, tokens: { out: 9 } }, + ]; + assert.equal(computeLogsSignature(snapshot), computeLogsSignature(structuredClone(snapshot))); +}); + +test("computeLogsSignature: changes when an in-progress request updates", () => { + const before = [{ id: "a", status: 0, duration: 0, tokens: { out: 0 } }]; + const afterStatus = [{ id: "a", status: 200, duration: 0, tokens: { out: 0 } }]; + const afterDuration = [{ id: "a", status: 0, duration: 42, tokens: { out: 0 } }]; + const afterTokens = [{ id: "a", status: 0, duration: 0, tokens: { out: 7 } }]; + const base = computeLogsSignature(before); + assert.notEqual(computeLogsSignature(afterStatus), base); + assert.notEqual(computeLogsSignature(afterDuration), base); + assert.notEqual(computeLogsSignature(afterTokens), base); +}); + +test("computeLogsSignature: detects additions and deletions", () => { + const one = [{ id: "a", status: 200, duration: 1, tokens: { out: 1 } }]; + const two = [...one, { id: "b", status: 200, duration: 1, tokens: { out: 1 } }]; + assert.notEqual(computeLogsSignature(one), computeLogsSignature(two)); +}); + +test("computeLogsSignature: non-array input collapses to empty", () => { + assert.equal(computeLogsSignature(undefined), ""); + assert.equal(computeLogsSignature(null), ""); + assert.equal(computeLogsSignature({ error: "boom" }), ""); + assert.equal(computeLogsSignature([]), ""); +}); + +test("computeLogsSignature: missing tokens defaults out to 0", () => { + assert.equal( + computeLogsSignature([{ id: "a", status: 200, duration: 5 }]), + "a:200:5:0" + ); +}); + +test("resolveInitialVisibility: visible by default when document is absent (SSR)", () => { + const original = (globalThis as any).document; + try { + delete (globalThis as any).document; + assert.equal(resolveInitialVisibility(), true); + } finally { + if (original !== undefined) (globalThis as any).document = original; + } +}); + +test("resolveInitialVisibility: mirrors document.visibilityState when present", () => { + const original = (globalThis as any).document; + try { + (globalThis as any).document = { visibilityState: "hidden" }; + assert.equal(resolveInitialVisibility(), false); + (globalThis as any).document = { visibilityState: "visible" }; + assert.equal(resolveInitialVisibility(), true); + } finally { + if (original === undefined) delete (globalThis as any).document; + else (globalThis as any).document = original; + } +});