fix(dashboard): event-driven fail-open auto-refresh for embedded log views (#4054) (#4103)

The Request Logger gated each auto-refresh tick on a static
document.visibilityState === "visible" read. Hosts that report a permanent
non-"visible" state without ever firing a visibilitychange event (Docker
dashboard wrappers, embedded/proxied webviews) froze auto-refresh entirely —
only the manual Refresh button worked, a regression from 3.8.24's unconditional
polling.

The pause is now event-driven and fail-open: visibleRef starts true and is only
flipped to false on a real visibilitychange → hidden transition, so a host that
never signals a genuine background transition keeps polling, while normal
browser tabs still pause when actually backgrounded.

Regression test reproduces the misreporting-host case (RED) and the perf guard
is re-encoded under the event-driven semantics.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-17 15:36:56 -03:00
committed by GitHub
parent c89adad0b4
commit 4941a0d462
3 changed files with 55 additions and 15 deletions

View File

@@ -15,6 +15,7 @@ _In development — bullets added per PR; finalized at release._
### 🐛 Fixed
- **fix(ws): start the LiveWS sidecar with `cwd` at the package root (global/systemd installs)** — the standalone LiveWS launcher (`scripts/start-ws-server.mjs`) re-spawns itself with `node --import tsx <self>` but did not set `cwd`. When the WebSocket sidecar was launched from outside the package directory — a global npm/homebrew install, or a `systemd`/`launchd` unit started from `$HOME` — Node could not resolve the `tsx` package (`ERR_MODULE_NOT_FOUND: Cannot find package 'tsx'`), and even from the package directory `tsx` could not resolve the tsconfig `@/*` path aliases (e.g. `@/types/databaseSettings`), so the sidecar never booted. The spawn now pins `cwd` to the package root (the directory above `scripts/`, where `package.json` + `tsconfig.json` live), which resolves both `tsx` discovery and the `@/*` aliases regardless of launch directory. ([#4055](https://github.com/diegosouzapw/OmniRoute/issues/4055) — thanks @Rahulsharma0810)
- **fix(dashboard): Logs page auto-refresh now works in embedded/proxied dashboards** — the Request Logger gated each auto-refresh tick on a static `document.visibilityState === "visible"` read. Hosts that report a permanent non-`"visible"` state without ever firing a `visibilitychange` event (Docker dashboard wrappers, embedded webviews) froze auto-refresh entirely — only the manual Refresh button worked, a regression from 3.8.24's unconditional polling. The pause is now event-driven and fail-open: polling starts enabled and only pauses after a real `visibilitychange` → hidden transition (still preserving the backgrounded-tab optimization for normal browser tabs). ([#4054](https://github.com/diegosouzapw/OmniRoute/issues/4054) — thanks @tjengbudi)
---

View File

@@ -28,11 +28,7 @@ import {
} from "@/shared/utils/formatting";
import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import {
computeLogsSignature,
resolveInitialVisibility,
shouldAutoRefresh,
} from "./requestLoggerSignature";
import { computeLogsSignature, shouldAutoRefresh } from "./requestLoggerSignature";
import {
DEFAULT_REFRESH_INTERVAL_SEC,
clampRefreshIntervalSec,
@@ -153,7 +149,12 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
const scrollContainerRef = useRef(null);
const loadMoreSentinelRef = useRef(null);
const [providerNodes, setProviderNodes] = useState([]);
const visibleRef = useRef(resolveInitialVisibility());
// #4054: fail-open. The auto-refresh pause is event-driven — we start assuming
// the tab is visible (poll) and only flip to paused on a real `visibilitychange`
// → hidden transition. Seeding from a static `document.visibilityState` read froze
// polling forever in embedded/proxied hosts that report a permanent non-"visible"
// state without ever dispatching the event (Docker dashboard wrappers, webviews).
const visibleRef = useRef(true);
// Column visibility with localStorage persistence
const [visibleColumns, setVisibleColumns] = useState(() => {
@@ -291,10 +292,13 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
if (intervalRef.current) clearInterval(intervalRef.current);
if (!selectedLog && shouldAutoRefresh(recording, limit, PAGE_SIZE)) {
intervalRef.current = setInterval(() => {
// #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") {
// #3972/#4054: gate on the event-tracked `visibleRef` (fail-open), not a
// static `document.visibilityState` read. A real browser tab that goes to
// the background fires `visibilitychange` → `visibleRef=false` → we pause
// (perf). An embedded/proxied host that misreports a permanent "hidden"
// state never fires the event, so `visibleRef` stays `true` and polling
// keeps working instead of freezing forever.
if (visibleRef.current) {
fetchLogs(false);
}
}, refreshIntervalSec * 1000);

View File

@@ -95,9 +95,9 @@ afterEach(async () => {
setVisibility("visible");
});
describe("RequestLoggerV2 auto-refresh (#3972)", () => {
describe("RequestLoggerV2 auto-refresh (#3972 + #4054)", () => {
it("keeps polling on the interval when the tab becomes visible without a visibilitychange event", async () => {
// Mounts while the document reports "hidden" → resolveInitialVisibility() = false.
// Mounts while the document reports "hidden", no visibilitychange ever fires.
setVisibility("hidden");
await act(async () => {
@@ -123,7 +123,14 @@ describe("RequestLoggerV2 auto-refresh (#3972)", () => {
expect(callLogsRequests).toBeGreaterThan(afterMount);
});
it("does not poll while the tab stays hidden (preserves the hidden-tab optimization)", async () => {
it("keeps polling when visibilityState is pinned 'hidden' but no visibilitychange ever fires (#4054)", async () => {
// Embedded / proxied dashboard host (e.g. a Docker wrapper or webview) that
// reports a permanent non-"visible" state and NEVER dispatches a
// `visibilitychange` event. 3.8.24 polled unconditionally; the static-visibility
// gate added since then froze auto-refresh in these hosts — only the manual
// Refresh button worked, exactly as reported in #4054. The gate must be
// fail-open: a host that never signals a *real* background transition keeps
// polling.
setVisibility("hidden");
await act(async () => {
@@ -133,12 +140,40 @@ describe("RequestLoggerV2 auto-refresh (#3972)", () => {
await vi.advanceTimersByTimeAsync(0);
});
const afterMount = callLogsRequests;
expect(afterMount).toBeGreaterThanOrEqual(1);
// Stays hidden across two ticks → must not poll.
// visibilityState stays "hidden", NO visibilitychange event is dispatched.
await act(async () => {
await vi.advanceTimersByTimeAsync(DEFAULT_REFRESH_INTERVAL_SEC * 1000);
});
expect(callLogsRequests).toBeGreaterThan(afterMount);
});
it("pauses polling after a real visibilitychange → hidden (preserves the backgrounded-tab optimization)", async () => {
// The perf guard is now keyed on the *event*, not the static value: a genuine
// background transition fires `visibilitychange`, and only then do we pause.
setVisibility("visible");
await act(async () => {
root.render(<RequestLoggerV2 />);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
// Real background transition: the state flips AND the browser fires the event.
setVisibility("hidden");
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
const afterHidden = callLogsRequests;
// Stays backgrounded across two ticks → must not poll.
await act(async () => {
await vi.advanceTimersByTimeAsync(DEFAULT_REFRESH_INTERVAL_SEC * 2000);
});
expect(callLogsRequests).toBe(afterMount);
expect(callLogsRequests).toBe(afterHidden);
});
});