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