From 3bf710eae9cec7b425236efafe8d982f37c9916e Mon Sep 17 00:00:00 2001 From: David Whittington Date: Wed, 30 Sep 2026 12:02:58 -0500 Subject: [PATCH] feat(studio): pause unified logs live mode when nobody is watching Live mode polled every 10 seconds for as long as the page stayed open, including in hidden tabs and tabs left unattended for hours. Move the polling loop into a useLivePolling hook that: - stops polling while the tab is hidden and polls immediately when it is visible again - pauses live mode after 15 minutes of visible time with no pointer, key, wheel, or scroll activity, with a notice to resume it - backs off from 10s to 20s, 40s, then 60s while polls find no new rows, resetting on new rows, activity, or the tab becoming visible Track clicks on the notice's "Resume live mode" button (unified_logs_live_mode_resume_button_clicked). Co-Authored-By: Claude Opus 5.5 --- .../components/ui/DataTable/LiveButton.tsx | 56 ++++-- .../ui/DataTable/LiveButton.utils.test.ts | 61 ++++++ .../ui/DataTable/LiveButton.utils.ts | 34 ++++ .../ui/DataTable/useLivePolling.test.ts | 183 ++++++++++++++++++ .../components/ui/DataTable/useLivePolling.ts | 120 ++++++++++++ packages/common/telemetry-constants.ts | 18 ++ 6 files changed, 452 insertions(+), 20 deletions(-) create mode 100644 apps/studio/components/ui/DataTable/LiveButton.utils.test.ts create mode 100644 apps/studio/components/ui/DataTable/LiveButton.utils.ts create mode 100644 apps/studio/components/ui/DataTable/useLivePolling.test.ts create mode 100644 apps/studio/components/ui/DataTable/useLivePolling.ts diff --git a/apps/studio/components/ui/DataTable/LiveButton.tsx b/apps/studio/components/ui/DataTable/LiveButton.tsx index a519539a942..3bfb4378b3d 100644 --- a/apps/studio/components/ui/DataTable/LiveButton.tsx +++ b/apps/studio/components/ui/DataTable/LiveButton.tsx @@ -2,14 +2,17 @@ import type { FetchPreviousPageOptions } from '@tanstack/react-query' import { CirclePause, CirclePlay } from 'lucide-react' import { useQueryStates } from 'nuqs' import { useEffect } from 'react' +import { toast } from 'sonner' import { Button } from 'ui' import { useDataTable } from './providers/DataTableProvider' +import { useLivePolling } from './useLivePolling' import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' +import { useTrack } from '@/lib/telemetry/track' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' -const REFRESH_INTERVAL = 10_000 +const LIVE_MODE_PAUSED_TOAST_ID = 'live-mode-paused-for-inactivity' interface LiveButtonProps { searchParamsParser: any @@ -19,26 +22,20 @@ interface LiveButtonProps { export function LiveButton({ fetchPreviousPage, searchParamsParser }: LiveButtonProps) { const [{ live, date, sort }, setSearch] = useQueryStates(searchParamsParser) const { table } = useDataTable() + const track = useTrack() useShortcut(SHORTCUT_IDS.DATA_TABLE_TOGGLE_LIVE, handleClick, { registerInCommandMenu: false }) + useLivePolling({ + isEnabled: Boolean(live), + poll: () => fetchPreviousPage?.() ?? Promise.resolve(undefined), + onIdle: handleIdle, + }) + + // The paused notice only makes sense while live mode is off, and only on this page. useEffect(() => { - let timeoutId: NodeJS.Timeout - - async function fetchData() { - if (live) { - await fetchPreviousPage?.() - timeoutId = setTimeout(fetchData, REFRESH_INTERVAL) - } else { - clearTimeout(timeoutId) - } - } - - fetchData() - - return () => { - clearTimeout(timeoutId) - } - }, [live, fetchPreviousPage]) + if (live) toast.dismiss(LIVE_MODE_PAUSED_TOAST_ID) + }, [live]) + useEffect(() => () => void toast.dismiss(LIVE_MODE_PAUSED_TOAST_ID), []) // REMINDER: make sure to reset live when date is set // TODO: test properly @@ -48,10 +45,11 @@ export function LiveButton({ fetchPreviousPage, searchParamsParser }: LiveButton } }, [date, sort]) - function handleClick() { + // Live mode always follows the newest logs, so turning it on or off clears any date range and sort. + function setLive(getLive: (wasLive: boolean) => boolean) { setSearch((prev) => ({ ...prev, - live: !prev.live, + live: getLive(Boolean(prev.live)), date: null, sort: null, })) @@ -59,6 +57,24 @@ export function LiveButton({ fetchPreviousPage, searchParamsParser }: LiveButton table.resetSorting() } + function handleClick() { + setLive((wasLive) => !wasLive) + } + + function handleIdle() { + setSearch((prev) => ({ ...prev, live: null })) + toast('Live mode paused after 15 minutes of inactivity', { + id: LIVE_MODE_PAUSED_TOAST_ID, + duration: Infinity, + action: { label: 'Resume live mode', onClick: handleResume }, + }) + } + + function handleResume() { + track('unified_logs_live_mode_resume_button_clicked', { pauseReason: 'inactivity' }) + setLive(() => true) + } + return ( { + it('polls at the base interval when the last poll found rows', () => { + expect(getLivePollDelay(0)).toBe(LIVE_POLL_BASE_INTERVAL_MS) + }) + + it('doubles the delay after each empty poll', () => { + expect(getLivePollDelay(1)).toBe(20_000) + expect(getLivePollDelay(2)).toBe(40_000) + }) + + it('caps the delay at the maximum interval', () => { + expect(getLivePollDelay(3)).toBe(LIVE_POLL_MAX_INTERVAL_MS) + expect(getLivePollDelay(50)).toBe(LIVE_POLL_MAX_INTERVAL_MS) + }) + + it('falls back to the base interval for negative or invalid counts', () => { + for (const count of [-1, NaN, Infinity]) { + expect(getLivePollDelay(count)).toBe(LIVE_POLL_BASE_INTERVAL_MS) + } + }) + + it('rounds fractional counts down', () => { + expect(getLivePollDelay(1.9)).toBe(20_000) + }) +}) + +describe('isEmptyLivePoll', () => { + const pollResult = (pages: unknown) => ({ data: { pages } }) + + it('is true when the newest page has no rows', () => { + expect(isEmptyLivePoll(pollResult([{ data: [] }, { data: [{ id: 'a' }] }]))).toBe(true) + }) + + it('is false when the newest page has rows', () => { + expect(isEmptyLivePoll(pollResult([{ data: [{ id: 'b' }] }, { data: [] }]))).toBe(false) + }) + + it('is false for missing or malformed results', () => { + for (const result of [ + undefined, + null, + {}, + { data: null }, + pollResult(undefined), + pollResult([]), + pollResult([null]), + pollResult([{ data: 'rows' }]), + ]) { + expect(isEmptyLivePoll(result)).toBe(false) + } + }) +}) diff --git a/apps/studio/components/ui/DataTable/LiveButton.utils.ts b/apps/studio/components/ui/DataTable/LiveButton.utils.ts new file mode 100644 index 00000000000..0a4804fd2e3 --- /dev/null +++ b/apps/studio/components/ui/DataTable/LiveButton.utils.ts @@ -0,0 +1,34 @@ +export const LIVE_POLL_BASE_INTERVAL_MS = 10_000 +export const LIVE_POLL_MAX_INTERVAL_MS = 60_000 +export const LIVE_MODE_IDLE_TIMEOUT_MS = 15 * 60_000 + +/** + * Delay before the next live-mode poll. Doubles after each poll that finds no new rows + * (10s, 20s, 40s), capped at 60s, so quiet projects are polled less often. + */ +export function getLivePollDelay(consecutiveEmptyPolls: number): number { + if (!Number.isFinite(consecutiveEmptyPolls) || consecutiveEmptyPolls <= 0) { + return LIVE_POLL_BASE_INTERVAL_MS + } + return Math.min( + LIVE_POLL_BASE_INTERVAL_MS * 2 ** Math.floor(consecutiveEmptyPolls), + LIVE_POLL_MAX_INTERVAL_MS + ) +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +/** + * Whether a live-mode poll found no new rows. A poll is `fetchPreviousPage`, which prepends + * the new page, so its result's first page holds the rows that poll returned. Anything + * unrecognized counts as not empty, so polling never slows down on unexpected data. + */ +export function isEmptyLivePoll(result: unknown): boolean { + if (!isRecord(result) || !isRecord(result.data)) return false + const pages = result.data.pages + if (!Array.isArray(pages) || pages.length === 0) return false + const newestPage = pages[0] + if (!isRecord(newestPage) || !Array.isArray(newestPage.data)) return false + return newestPage.data.length === 0 +} diff --git a/apps/studio/components/ui/DataTable/useLivePolling.test.ts b/apps/studio/components/ui/DataTable/useLivePolling.test.ts new file mode 100644 index 00000000000..b86f45756c3 --- /dev/null +++ b/apps/studio/components/ui/DataTable/useLivePolling.test.ts @@ -0,0 +1,183 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { LIVE_MODE_IDLE_TIMEOUT_MS } from './LiveButton.utils' +import { useLivePolling } from './useLivePolling' + +const SECOND = 1_000 +const MINUTE = 60 * SECOND + +const pageResult = (rows: unknown[]) => ({ data: { pages: [{ data: rows }] } }) +const NEW_ROWS = pageResult([{ id: 'a' }]) +const NO_ROWS = pageResult([]) + +let visibilityState: DocumentVisibilityState = 'visible' + +const setVisibility = async (state: DocumentVisibilityState) => { + visibilityState = state + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')) + }) +} + +const advance = async (ms: number) => { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms) + }) +} + +const interact = async () => { + await act(async () => { + window.dispatchEvent(new Event('pointerdown')) + }) +} + +const setup = ({ result = NEW_ROWS, isEnabled = true } = {}) => { + const poll = vi.fn(() => Promise.resolve(result)) + const onIdle = vi.fn() + const hook = renderHook( + (props: { isEnabled: boolean }) => useLivePolling({ isEnabled: props.isEnabled, poll, onIdle }), + { initialProps: { isEnabled } } + ) + return { poll, onIdle, hook } +} + +describe('useLivePolling', () => { + beforeEach(() => { + vi.useFakeTimers() + visibilityState = 'visible' + vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('polls immediately, then every 10 seconds while polls find rows', async () => { + const { poll } = setup() + await advance(0) + expect(poll).toHaveBeenCalledTimes(1) + + await advance(9 * SECOND) + expect(poll).toHaveBeenCalledTimes(1) + await advance(1 * SECOND) + expect(poll).toHaveBeenCalledTimes(2) + await advance(10 * SECOND) + expect(poll).toHaveBeenCalledTimes(3) + }) + + it('does not poll while disabled, and stops when disabled', async () => { + const { poll, hook } = setup({ isEnabled: false }) + await advance(MINUTE) + expect(poll).not.toHaveBeenCalled() + + hook.rerender({ isEnabled: true }) + await advance(0) + expect(poll).toHaveBeenCalledTimes(1) + + hook.rerender({ isEnabled: false }) + await advance(5 * MINUTE) + expect(poll).toHaveBeenCalledTimes(1) + }) + + it('backs off to 20s, 40s, then 60s while polls find no new rows', async () => { + const { poll } = setup({ result: NO_ROWS }) + await advance(0) // poll 1 at 0s + await advance(20 * SECOND) // poll 2 at 20s + expect(poll).toHaveBeenCalledTimes(2) + await advance(39 * SECOND) + expect(poll).toHaveBeenCalledTimes(2) + await advance(1 * SECOND) // poll 3 at 60s + expect(poll).toHaveBeenCalledTimes(3) + await advance(60 * SECOND) // poll 4 at 120s + await advance(60 * SECOND) // poll 5 at 180s (capped) + expect(poll).toHaveBeenCalledTimes(5) + }) + + it('returns to the base interval once a poll finds rows again', async () => { + const { poll } = setup({ result: NO_ROWS }) + await advance(0) + await advance(20 * SECOND) // second empty poll; next delay 40s + poll.mockResolvedValue(NEW_ROWS) + await advance(40 * SECOND) // this poll finds rows + expect(poll).toHaveBeenCalledTimes(3) + await advance(10 * SECOND) + expect(poll).toHaveBeenCalledTimes(4) + }) + + it('resets the backoff when the user interacts with the page', async () => { + const { poll } = setup({ result: NO_ROWS }) + await advance(0) + await advance(20 * SECOND) + await advance(40 * SECOND) // three empty polls; next delay 60s + expect(poll).toHaveBeenCalledTimes(3) + + await interact() + await advance(10 * SECOND) + expect(poll).toHaveBeenCalledTimes(4) + }) + + it('stops polling while the tab is hidden and polls immediately when it is visible again', async () => { + const { poll } = setup() + await advance(0) + expect(poll).toHaveBeenCalledTimes(1) + + await setVisibility('hidden') + await advance(5 * MINUTE) + expect(poll).toHaveBeenCalledTimes(1) + + await setVisibility('visible') + await advance(0) + expect(poll).toHaveBeenCalledTimes(2) + await advance(10 * SECOND) + expect(poll).toHaveBeenCalledTimes(3) + }) + + it('does not start polling when enabled in a hidden tab', async () => { + visibilityState = 'hidden' + const { poll } = setup() + await advance(MINUTE) + expect(poll).not.toHaveBeenCalled() + }) + + it('calls onIdle after 15 minutes without activity', async () => { + const { onIdle } = setup() + await advance(LIVE_MODE_IDLE_TIMEOUT_MS - SECOND) + expect(onIdle).not.toHaveBeenCalled() + await advance(SECOND) + expect(onIdle).toHaveBeenCalledTimes(1) + }) + + it('restarts the idle timeout on user activity', async () => { + const { onIdle } = setup() + await advance(10 * MINUTE) + await interact() + await advance(14 * MINUTE) + expect(onIdle).not.toHaveBeenCalled() + await advance(MINUTE) + expect(onIdle).toHaveBeenCalledTimes(1) + }) + + it('does not count hidden time toward the idle timeout', async () => { + const { onIdle } = setup() + await advance(10 * MINUTE) + await setVisibility('hidden') + await advance(30 * MINUTE) + expect(onIdle).not.toHaveBeenCalled() + + await setVisibility('visible') + await advance(LIVE_MODE_IDLE_TIMEOUT_MS - SECOND) + expect(onIdle).not.toHaveBeenCalled() + await advance(SECOND) + expect(onIdle).toHaveBeenCalledTimes(1) + }) + + it('keeps polling after a failed poll', async () => { + const { poll } = setup() + poll.mockRejectedValueOnce(new Error('network')) + await advance(0) + await advance(10 * SECOND) + expect(poll).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/studio/components/ui/DataTable/useLivePolling.ts b/apps/studio/components/ui/DataTable/useLivePolling.ts new file mode 100644 index 00000000000..fcedc7a1f3b --- /dev/null +++ b/apps/studio/components/ui/DataTable/useLivePolling.ts @@ -0,0 +1,120 @@ +import { useEffect, useRef } from 'react' + +import { + getLivePollDelay, + isEmptyLivePoll, + LIVE_MODE_IDLE_TIMEOUT_MS, + LIVE_POLL_BASE_INTERVAL_MS, +} from './LiveButton.utils' + +const ACTIVITY_EVENTS = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'scroll'] as const +const ACTIVITY_THROTTLE_MS = 1_000 + +interface UseLivePollingOptions { + isEnabled: boolean + /** Fetches new rows. Its resolved value is inspected to back off when nothing new arrives. */ + poll: () => Promise + /** Called once the page has been visible with no user activity for the idle timeout. */ + onIdle: () => void +} + +/** + * Runs live-mode polling while `isEnabled`: + * - Polls every 10s, backing off to 60s while polls find no new rows. + * - Stops while the tab is hidden, and polls immediately when it becomes visible again. + * - Calls `onIdle` after 15 minutes of visible time without pointer, key, wheel, or scroll activity. + * Activity and becoming visible reset the backoff. + */ +export function useLivePolling({ isEnabled, poll, onIdle }: UseLivePollingOptions) { + // Latest callbacks, so a new function identity doesn't restart the polling loop. + const pollRef = useRef(poll) + const onIdleRef = useRef(onIdle) + useEffect(() => { + pollRef.current = poll + onIdleRef.current = onIdle + }, [poll, onIdle]) + + useEffect(() => { + if (!isEnabled) return + + let isStopped = false + let isPolling = false + let consecutiveEmptyPolls = 0 + let pollTimeoutId: ReturnType | undefined + let idleTimeoutId: ReturnType | undefined + let lastActivityAt = 0 + + const isHidden = () => document.visibilityState === 'hidden' + + const schedulePoll = (delay: number) => { + clearTimeout(pollTimeoutId) + pollTimeoutId = setTimeout(runPoll, delay) + } + + async function runPoll() { + if (isStopped || isHidden() || isPolling) return + isPolling = true + try { + const result = await pollRef.current() + consecutiveEmptyPolls = isEmptyLivePoll(result) ? consecutiveEmptyPolls + 1 : 0 + } catch { + // A failed poll shouldn't end live mode; retry at the current backoff. + } finally { + isPolling = false + } + if (!isStopped && !isHidden()) schedulePoll(getLivePollDelay(consecutiveEmptyPolls)) + } + + const startIdleTimer = () => { + clearTimeout(idleTimeoutId) + idleTimeoutId = setTimeout(() => { + if (!isStopped) onIdleRef.current() + }, LIVE_MODE_IDLE_TIMEOUT_MS) + } + + const handleActivity = () => { + const now = Date.now() + if (now - lastActivityAt < ACTIVITY_THROTTLE_MS) return + lastActivityAt = now + startIdleTimer() + // Someone is watching again: return to the base interval instead of waiting out a backoff. + if (consecutiveEmptyPolls > 0) { + consecutiveEmptyPolls = 0 + if (!isPolling && !isHidden()) schedulePoll(LIVE_POLL_BASE_INTERVAL_MS) + } + } + + const handleVisibilityChange = () => { + if (isHidden()) { + // Hidden tabs neither poll nor count toward the idle timeout. + clearTimeout(pollTimeoutId) + clearTimeout(idleTimeoutId) + return + } + consecutiveEmptyPolls = 0 + startIdleTimer() + clearTimeout(pollTimeoutId) + void runPoll() + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + for (const event of ACTIVITY_EVENTS) { + window.addEventListener(event, handleActivity, { passive: true, capture: true }) + } + + if (!isHidden()) { + startIdleTimer() + void runPoll() + } + + return () => { + isStopped = true + clearTimeout(pollTimeoutId) + clearTimeout(idleTimeoutId) + document.removeEventListener('visibilitychange', handleVisibilityChange) + for (const event of ACTIVITY_EVENTS) { + window.removeEventListener(event, handleActivity, { capture: true }) + } + } + }, [isEnabled]) +} diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index bf8bed7b580..0051cf431d6 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -3817,6 +3817,23 @@ export interface ResourceExhaustionBannerTroubleshootClickedEvent { } } +/** + * User clicked "Resume live mode" in the notice shown after Unified Logs paused live mode + * because the page was visible with no user activity for 15 minutes. + * + * @group Events + * @source studio + * @page /dashboard/project/{ref}/logs + */ +export interface UnifiedLogsLiveModeResumeButtonClickedEvent { + action: 'unified_logs_live_mode_resume_button_clicked' + properties: { + /** Why live mode was paused before the user resumed it. */ + pauseReason: 'inactivity' + } + groups: TelemetryGroups +} + /** * User clicked a row in the Unified Logs interface. * @@ -4266,6 +4283,7 @@ export type TelemetryEvent = | ResourceExhaustionBannerAiAssistantClickedEvent | ResourceExhaustionBannerTroubleshootClickedEvent | UnifiedLogsRowClickedEvent + | UnifiedLogsLiveModeResumeButtonClickedEvent | HeaderHomeLogoClickedEvent | HeaderBackToDashboardClickedEvent | HeaderExceedingUsageBadgeClickedEvent