mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
perf(logs): fix browser freeze and network saturation on /dashboard/logs (#3109)
* feat(providers): implement bulk paste for extra API keys Adds `parseExtraApiKeys` utility to process multi-line key inputs. Integrates bulk paste functionality into the provider connection modal. Users can now paste multiple API keys, one per line, into the input field. Provides notifications for successfully added keys and ignored duplicates. Adds a "Delete all" button to clear all extra API keys. Updates i18n messages for new features and improved key masking/pluralization. * refactor(providers): streamline API key bulk paste and i18n Remove unused return from `handleAddParsedExtraKeys` to clean up code. Adjust `onPaste` to allow default paste for single-line input, improving UX. Remove obsolete bulk paste UI translation keys to reduce bundle size. Update pluralization for bulk paste messages to ensure correct grammar. Refine Portuguese (Brazil) API key translations for clarity. * fix(logs): apply code review feedback - robust signature, immediate fetch on tab restore, reuse memoized apiKeyCount * test(logs): extract pure polling/signature helpers + cover them (#3109) Hard rule #8: the perf fix touched src/ without tests. Extract computeLogsSignature, shouldAutoRefresh and resolveInitialVisibility into a pure module and unit-test them (change-detection, first-page polling guard, SSR/hidden-tab visibility init). Also fixes visibleRef to honor the real visibilityState on mount instead of hardcoding true (no poll when mounted in a hidden tab). --------- Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
38
src/shared/components/requestLoggerSignature.ts
Normal file
38
src/shared/components/requestLoggerSignature.ts
Normal file
@@ -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("|");
|
||||
}
|
||||
79
tests/unit/request-logger-signature.test.ts
Normal file
79
tests/unit/request-logger-signature.test.ts
Normal file
@@ -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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user