mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
a756fd36ef
commit
3bf710eae9
6 files changed
+452
-20
No files matched your search
@@ -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 (
|
||||
<ShortcutTooltip
|
||||
shortcutId={SHORTCUT_IDS.DATA_TABLE_TOGGLE_LIVE}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getLivePollDelay,
|
||||
isEmptyLivePoll,
|
||||
LIVE_POLL_BASE_INTERVAL_MS,
|
||||
LIVE_POLL_MAX_INTERVAL_MS,
|
||||
} from './LiveButton.utils'
|
||||
|
||||
describe('getLivePollDelay', () => {
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> =>
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<unknown>
|
||||
/** 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<typeof setTimeout> | undefined
|
||||
let idleTimeoutId: ReturnType<typeof setTimeout> | 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])
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user