fix(dashboard): logs auto-refresh reads live visibility, not a stale mount ref (#3972) (#3997)

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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-16 09:32:19 -03:00
committed by GitHub
parent 074f6188de
commit bb40fbba09
4 changed files with 153 additions and 2 deletions

View File

@@ -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 `<baseUrl>/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)

View File

@@ -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 <baseUrl>/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,

View File

@@ -283,7 +283,12 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
if (intervalRef.current) clearInterval(intervalRef.current);
if (!selectedLog && shouldAutoRefresh(recording, limit, PAGE_SIZE)) {
intervalRef.current = setInterval(() => {
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 () => {

View File

@@ -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(<RequestLoggerV2 />);
});
// 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(<RequestLoggerV2 />);
});
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);
});
});