From bb40fbba0953a38b4b0accd89ccd00e382c24326 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:32:19 -0300 Subject: [PATCH] fix(dashboard): logs auto-refresh reads live visibility, not a stale mount ref (#3972) (#3997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-refresh interval gated each tick on visibleRef, seeded once at mount and updated only by a visibilitychange event. A tab mounted while document.visibilityState is 'hidden' (background load, bfcache, embedded/proxied webviews) with no later visibilitychange left the ref false forever, so the interval ticked but never fetched — only the manual button worked. Read the live document.visibilityState in the tick instead. --- CHANGELOG.md | 1 + config/quality/file-size-baseline.json | 3 +- src/shared/components/RequestLoggerV2.tsx | 7 +- ...ogger-autorefresh-visibility-3972.test.tsx | 144 ++++++++++++++++++ 4 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 tests/unit/request-logger-autorefresh-visibility-3972.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index d2021b5982..7a8ea4a457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ ### 🐛 Fixed +- **fix(dashboard): Logs auto-refresh now works even when the tab loads in the background** — the Logs page never auto-refreshed (only the manual Refresh button worked). The auto-refresh interval gated each tick on a visibility ref seeded once at mount and updated only by a `visibilitychange` event; when the tab mounted while the document reported `hidden` (background load, bfcache restore, embedded/Docker-proxied webviews) and no `visibilitychange` ever fired, the ref stayed `false` forever, so the interval ticked but never fetched. The tick now reads the live `document.visibilityState`, so polling self-heals as soon as the tab is visible — while still pausing when genuinely hidden. ([#3972](https://github.com/diegosouzapw/OmniRoute/issues/3972) — thanks @tjengbudi) - **fix(providers): LLM7 (and BytePlus) now fetch the live `/models` catalog instead of a stale hardcoded list** — importing an LLM7 key surfaced only a small, outdated model list even though `GET https://api.llm7.io/v1/models` returns the full pro/standard catalog. Both providers carried a correct `modelsUrl` in the registry, but neither was classified by any live-fetch branch of the model-import route (not `openai-compatible-*`, not self-hosted, not in `NAMED_OPENAI_STYLE_PROVIDERS`), so the route skipped the upstream probe and served the registry's 4 hardcoded entries (`source: "local_catalog"`). Added `llm7` and `byteplus` to `NAMED_OPENAI_STYLE_PROVIDERS` so the route probes `/models` with the key and serves the live catalog, falling back to the local catalog only when the upstream fetch fails (so key import never breaks). ([#3976](https://github.com/diegosouzapw/OmniRoute/issues/3976) — thanks @FerLuisxd) - **fix(resilience): respect connection cooldown stored as a numeric epoch (router kept hammering 429 accounts)** — the router kept dispatching to connections still inside their rate-limit cooldown, causing client timeouts and "connection cooldown isn't respected" reports. Root cause: `rate_limited_until` is a `TEXT` column, but the Antigravity full-quota path (`setConnectionRateLimitUntil`) persists a raw epoch **number**, which SQLite coerces to a numeric string like `"1781696905131.0"`. The account-selection predicate then did `new Date("1781696905131.0")` → `Invalid Date` → `NaN`, so `NaN > Date.now()` was false and the cooling connection was never skipped. The cooldown read predicates (`isAccountUnavailable`, `getEarliestRateLimitedUntil`, `filterAvailableAccounts`, `parseFutureDateMs`) now normalize numeric-epoch strings as well as ISO strings/Date/number via a shared `cooldownUntilMs()` helper — ISO behavior is unchanged. ([#3954](https://github.com/diegosouzapw/OmniRoute/issues/3954)) - **fix(compression/memory): stop memory + compression from poisoning the upstream prompt cache** — with compression and/or memory enabled, requests to caching providers (Anthropic-family) missed the prompt cache on every turn, multiplying cost. Two root causes: (1) memory injection prepended the retrieved memories — which **vary per user query** — at index 0 of the message array, shifting the entire cacheable prefix every turn; memory is now inserted just before the last user message when the request carries `cache_control` breakpoints, keeping the cacheable prefix (system prompt + prior turns) byte-stable. (2) the cache-aware `skipSystemPrompt` flag computed by `getCacheAwareStrategy()` was dropped by `selectCompressionStrategy()` (which can only return a mode), so the system prompt could still be compressed under caching; a new `resolveCacheAwareConfig()` now forces `preserveSystemPrompt` on for caching requests. ([#3936](https://github.com/diegosouzapw/OmniRoute/pull/3936), closes [#3890](https://github.com/diegosouzapw/OmniRoute/issues/3890) — thanks @xenstar / @diegosouzapw) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index b019355043..335f03c53b 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -15,6 +15,7 @@ "_rebaseline_2026_06_15_3929_vertex_media": "PR #3929 own growth: audioSpeech.ts 952->965 (+13) and videoGeneration.ts 1026->1078 (+52) = vertex/* media branches (Gemini TTS, Veo predictLongRunning poll) wired into the speech/video handlers; new logic lives in open-sse/executors/vertexMedia.ts (341, under cap). Cohesive media-provider feature.", "_rebaseline_2026_06_15_3879_redact_thinking": "PR #3879 + #3921 reconcile: AddApiKeyModal.tsx 843->845 (+2 = merging #3879's CcCompatibleRequestDefaultsFields (context1m + opt-in redact-thinking toggle) into #3921's preset-input block in the cc-compatible settings group). Cohesive UI; not extractable.", "_rebaseline_2026_06_15_3890_cache_preserve": "Issue #3890 own growth: chatCore.ts 5815->5823 (+8 = wire resolveCacheAwareConfig() into the compression apply step so the system prompt is never compressed in a caching context — honors the cache-aware skipSystemPrompt flag that selectCompressionStrategy could not carry). Cohesive cache-preservation guard at the existing compression chokepoint; not extractable.", + "_rebaseline_2026_06_16_3972_logs_autorefresh": "Issue #3972 own growth: RequestLoggerV2.tsx 1282->1287 (+5 = the auto-refresh interval now reads the live document.visibilityState each tick instead of a stale mount-time ref, plus a 3-line comment explaining the hidden-tab trap). Cohesive one-spot fix; structural shrink of this component tracked in #3501.", "_rebaseline_2026_06_16_3976_llm7_byteplus_models": "Issue #3976 own growth: models/route.ts 2489->2494 (+5 = add llm7 + byteplus to NAMED_OPENAI_STYLE_PROVIDERS with an explanatory comment so the import route does a live /models fetch instead of serving the stale hardcoded registry catalog). Structural shrink of this route tracked in #3789.", "_rebaseline_2026_06_16_3954_cooldown_epoch": "Issue #3954 own growth: accountFallback.ts 1708->1727 (+19 = a shared cooldownUntilMs() normalizer + its use in isAccountUnavailable/getEarliestRateLimitedUntil/filterAvailableAccounts so a rate_limited_until persisted as a numeric-epoch string is honored, not parsed to NaN) and auth.ts 2216->2219 (+3 = parseFutureDateMs reuses cooldownUntilMs). Cohesive cooldown read-path hardening at the existing chokepoints; one helper, not extractable.", "_rebaseline_2026_06_15_3938_perplexity_v218": "PR #3938 own growth: perplexity-web.ts 868->939 (+71 = rebuild buildPplxRequestBody to mirror the current www.perplexity.ai schematized request body — version 2.18, use_schematized_api + the full supported_block_use_cases list, dsl_query, shared requestId for frontend_uuid/client_search_results_cache_key, last_backend_uuid only on follow-ups — plus the x-perplexity-request-* / x-request-id headers replacing the stale X-App-ApiVersion pair that triggered HTTP 400). Cohesive upstream-schema sync in a single executor; not extractable.", @@ -112,7 +113,7 @@ "src/lib/usage/providerLimits.ts": 949, "src/lib/usage/usageHistory.ts": 854, "src/shared/components/OAuthModal.tsx": 960, - "src/shared/components/RequestLoggerV2.tsx": 1282, + "src/shared/components/RequestLoggerV2.tsx": 1287, "src/shared/components/analytics/charts.tsx": 1558, "src/shared/constants/cliTools.ts": 875, "src/shared/constants/pricing.ts": 1529, diff --git a/src/shared/components/RequestLoggerV2.tsx b/src/shared/components/RequestLoggerV2.tsx index 111c2260a1..039c2f5615 100644 --- a/src/shared/components/RequestLoggerV2.tsx +++ b/src/shared/components/RequestLoggerV2.tsx @@ -283,7 +283,12 @@ const RequestLoggerV2 = forwardRef { - if (visibleRef.current) fetchLogs(false); + // #3972: read live visibility each tick, not a mount-time ref. A tab + // mounted while "hidden" with no later `visibilitychange` left the ref + // false forever, so auto-refresh never ran (only the manual button did). + if (typeof document === "undefined" || document.visibilityState === "visible") { + fetchLogs(false); + } }, refreshIntervalSec * 1000); } return () => { diff --git a/tests/unit/request-logger-autorefresh-visibility-3972.test.tsx b/tests/unit/request-logger-autorefresh-visibility-3972.test.tsx new file mode 100644 index 0000000000..300749beb4 --- /dev/null +++ b/tests/unit/request-logger-autorefresh-visibility-3972.test.tsx @@ -0,0 +1,144 @@ +// @vitest-environment jsdom +/** + * TDD regression for #3972: Logs page auto-refresh is broken — it never polls + * until the manual Refresh button is clicked. + * + * Root cause: the auto-refresh interval gated each tick on `visibleRef.current`, + * a ref seeded once at mount from `document.visibilityState` and only updated by + * a `visibilitychange` event. When the logs tab mounts while the document is + * reported "hidden" (background load, bfcache restore, embedded/proxied webviews) + * and no `visibilitychange` ever fires, the ref stays `false` forever — the + * interval ticks but never calls `fetchLogs`, so auto-refresh produces zero + * requests. The manual button (no gate) still works, matching the report. + * + * Fix: the tick reads the live `document.visibilityState` instead of the stale + * ref, so polling self-heals as soon as the tab is visible. + * + * This test mounts hidden, then flips visibility to "visible" WITHOUT dispatching + * a `visibilitychange` event, and asserts the 10s tick still polls. + */ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn(), refresh: vi.fn() }), + useSearchParams: () => new URLSearchParams(), + usePathname: () => "/dashboard/logs", +})); + +vi.mock("@/store/emailPrivacyStore", () => ({ + default: () => ({ emailsVisible: true }), +})); + +const RequestLoggerV2 = (await import("../../src/shared/components/RequestLoggerV2.tsx")).default; +const { DEFAULT_REFRESH_INTERVAL_SEC } = await import( + "../../src/shared/components/requestLoggerPreferences.ts" +); + +function setVisibility(state: "visible" | "hidden") { + Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state }); +} + +class FakeIntersectionObserver { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } +} + +let callLogsRequests = 0; +let container: HTMLElement; +let root: Root; + +beforeEach(() => { + callLogsRequests = 0; + localStorage.clear(); + vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith("/api/usage/call-logs")) { + callLogsRequests += 1; + return new Response("[]", { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.startsWith("/api/provider-nodes")) { + return Response.json({ nodes: [] }); + } + if (url.startsWith("/api/logs/detail")) { + return Response.json({ enabled: false }); + } + return Response.json({}); + }) + ); + vi.useFakeTimers(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + setVisibility("visible"); +}); + +describe("RequestLoggerV2 auto-refresh (#3972)", () => { + it("keeps polling on the interval when the tab becomes visible without a visibilitychange event", async () => { + // Mounts while the document reports "hidden" → resolveInitialVisibility() = false. + setVisibility("hidden"); + + await act(async () => { + root.render(); + }); + // Settle the mount fetches (logs + provider-nodes + detail). + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + const afterMount = callLogsRequests; + expect(afterMount).toBeGreaterThanOrEqual(1); // initial load fired + + // Tab becomes visible, but NO `visibilitychange` event is dispatched — this is + // the trap: the old code's visibleRef would stay false forever. + setVisibility("visible"); + + // One auto-refresh interval tick (10s). + await act(async () => { + await vi.advanceTimersByTimeAsync(DEFAULT_REFRESH_INTERVAL_SEC * 1000); + }); + + expect(callLogsRequests).toBeGreaterThan(afterMount); + }); + + it("does not poll while the tab stays hidden (preserves the hidden-tab optimization)", async () => { + setVisibility("hidden"); + + await act(async () => { + root.render(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + const afterMount = callLogsRequests; + + // Stays hidden across two ticks → must not poll. + await act(async () => { + await vi.advanceTimersByTimeAsync(DEFAULT_REFRESH_INTERVAL_SEC * 2000); + }); + + expect(callLogsRequests).toBe(afterMount); + }); +});