diff --git a/.changeset/early-banks-retire.md b/.changeset/early-banks-retire.md new file mode 100644 index 0000000000..735f033102 --- /dev/null +++ b/.changeset/early-banks-retire.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': patch +--- + +Show elapsed time and Generated SQL for search timeline view diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 3aea34d706..6b1c0b1542 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -25,6 +25,7 @@ import { import { useForm, useWatch } from 'react-hook-form'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; +import HyperDX from '@hyperdx/browser'; import { ClickHouseQueryError, ColumnMeta, @@ -72,13 +73,14 @@ import { notifications } from '@mantine/notifications'; import { IconArrowBarToRight, IconBolt, + IconCode, IconPlayerPlay, IconPlus, IconStack2, IconTags, IconX, } from '@tabler/icons-react'; -import { useIsFetching } from '@tanstack/react-query'; +import { keepPreviousData, useIsFetching } from '@tanstack/react-query'; import { SortingState } from '@tanstack/react-table'; import CodeMirror from '@uiw/react-codemirror'; @@ -120,9 +122,14 @@ import { parseTimeQuery, useNewTimeQuery, } from '@/timeQuery'; -import { QUERY_LOCAL_STORAGE, useLocalStorage, usePrevious } from '@/utils'; +import { + formatDurationMs, + QUERY_LOCAL_STORAGE, + useLocalStorage, + usePrevious, +} from '@/utils'; -import { SQLPreview } from './components/ChartSQLPreview'; +import ChartSQLPreview, { SQLPreview } from './components/ChartSQLPreview'; import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; import PatternTable from './components/PatternTable'; import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart'; @@ -330,30 +337,93 @@ function SearchResultsCountGroup({ ); } -function SearchNumRows({ +export function SearchNumRows({ config, + sqlConfig, enabled, + searchElapsedMs, + isSearching, + isLiveTail = false, }: { config: ChartConfigWithDateRange; + sqlConfig?: ChartConfigWithDateRange; enabled: boolean; + searchElapsedMs: number | null; + isSearching: boolean; + isLiveTail?: boolean; }) { + const [statsOpened, { open: openStats, close: closeStats }] = + useDisclosure(false); const { data, isLoading, error } = useExplainQuery(config, { enabled, + // Keep the previous row count on screen while a new EXPLAIN runs so the + // "Scanned Rows" value doesn't flash a loading state on every live-tail + // poll (each poll changes the dateRange, and thus the query key). + placeholderData: keepPreviousData, }); if (!enabled) { return null; } - const numRows = data?.[0]?.rows; + const explainRow = data?.[0]; + const numRows = explainRow?.rows; + const hasData = !isLoading && !error && numRows != null; + + // During live tail we keep showing the last measured elapsed time and never + // flash the "..." loading state, so the value doesn't flicker between polls. + const showElapsedLoading = isSearching && !isLiveTail; + const showElapsed = showElapsedLoading || searchElapsedMs != null; + return ( - - {isLoading - ? 'Scanned Rows ...' - : error || !numRows - ? '' - : `Scanned Rows: ${Number.parseInt(numRows)?.toLocaleString()}`} - + <> + + + + + + {isLoading + ? 'Scanned Rows ...' + : error || numRows == null + ? '' + : `Scanned Rows: ${Number(numRows).toLocaleString()}`} + + {showElapsed && ( + <> + {(hasData || isLoading) && ( + + | + + )} + + {showElapsedLoading + ? 'Elapsed Time: ...' + : `Elapsed Time: ${formatDurationMs(searchElapsedMs!)}`} + + + )} + {/* The generated-SQL preview is derived purely from config, not the + explain query, so it renders unconditionally. Gating it on explain + loading/data would make it flicker on every live-tail poll, since + each poll changes the dateRange (and thus the explain queryKey). */} + + + + + + + ); } @@ -810,6 +880,79 @@ const queryStateMap = { orderBy: parseAsStringEncoded, }; +export function useSearchTelemetry({ + isAnyQueryFetching, + isLive, + sourceId, +}: { + isAnyQueryFetching: boolean; + /** When true the hook suppresses recording and emission so live-tail + * background refetches do not flood the metric. */ + isLive: boolean; + sourceId: string | null; +}) { + const searchStartTimeRef = useRef(null); + const wasFetchingRef = useRef(false); + // Whether the in-flight cycle began as a live-tail refresh, captured on the + // rising edge so a mid-cycle isLive flip can't change how it's treated. + const cycleIsLiveRef = useRef(false); + + // Snapshot latency_ms and source_id together so a later sourceId change does + // not cause the emission effect to re-fire with stale latency data. `emit` + // records whether this cycle should be reported to telemetry (user-initiated + // searches only); latency is still surfaced for display in every case. + const [completedSearch, setCompletedSearch] = useState<{ + latency_ms: number; + source_id: string; + emit: boolean; + } | null>(null); + + useEffect(() => { + if (isAnyQueryFetching) { + // Start the timer once per fetch cycle (for live tail too — we display + // its elapsed time, we just don't emit telemetry for it). + if (!wasFetchingRef.current) { + searchStartTimeRef.current = performance.now(); + cycleIsLiveRef.current = isLive; + // Only blank the displayed timer for user-initiated searches. During + // live tail we keep the previous value so it doesn't flicker between + // background refreshes. + if (!isLive) { + setCompletedSearch(null); + } + } + wasFetchingRef.current = true; + } else { + if (searchStartTimeRef.current != null) { + setCompletedSearch({ + latency_ms: Math.round( + performance.now() - searchStartTimeRef.current, + ), + source_id: sourceId ?? '', + emit: !cycleIsLiveRef.current, + }); + searchStartTimeRef.current = null; + } + wasFetchingRef.current = false; + } + }, [isAnyQueryFetching, isLive, sourceId]); + + // completedSearch is the only dep here — sourceId was snapshotted at + // completion time so changing source after a finished search does not + // re-emit the previous run's latency against the new source. Live-tail + // cycles are recorded for display but flagged emit=false so they never flood + // telemetry. + useEffect(() => { + if (completedSearch == null || !completedSearch.emit) return; + HyperDX.addAction('search executed', { + latency_ms: completedSearch.latency_ms, + source_id: completedSearch.source_id, + }); + }, [completedSearch]); + + return { searchElapsedMs: completedSearch?.latency_ms ?? null }; +} + export function DBSearchPage() { const brandName = useBrandDisplayName(); // Next router is laggy behind window.location, which causes race @@ -1339,6 +1482,12 @@ export function DBSearchPage() { queryKey: [QUERY_KEY_PREFIX], }) > 0; + const { searchElapsedMs } = useSearchTelemetry({ + isAnyQueryFetching, + isLive: isLive ?? false, + sourceId: chartConfig?.source ?? null, + }); + const isTabVisible = useDocumentVisibility(); // State for collapsing all expanded rows when resuming live tail @@ -2193,7 +2342,11 @@ export function DBSearchPage() { ...chartConfig, dateRange: searchedTimeRange, }} + sqlConfig={histogramTimeChartConfig ?? undefined} enabled={isReady} + searchElapsedMs={searchElapsedMs} + isSearching={isAnyQueryFetching} + isLiveTail={isLive ?? false} /> @@ -2287,7 +2440,11 @@ export function DBSearchPage() { ...chartConfig, dateRange: searchedTimeRange, }} + sqlConfig={histogramTimeChartConfig ?? undefined} enabled={isReady} + searchElapsedMs={searchElapsedMs} + isSearching={isAnyQueryFetching} + isLiveTail={isLive ?? false} /> diff --git a/packages/app/src/__tests__/useSearchTelemetry.test.tsx b/packages/app/src/__tests__/useSearchTelemetry.test.tsx new file mode 100644 index 0000000000..15892b5b67 --- /dev/null +++ b/packages/app/src/__tests__/useSearchTelemetry.test.tsx @@ -0,0 +1,558 @@ +import React from 'react'; +import { act, fireEvent, renderHook, screen } from '@testing-library/react'; + +import { SearchNumRows, useSearchTelemetry } from '@/DBSearchPage'; + +jest.mock('@/layout', () => ({ + withAppNav: (component: unknown) => component, +})); + +const mockAddAction = jest.fn(); +jest.mock('@hyperdx/browser', () => ({ + __esModule: true, + default: { addAction: (...args: unknown[]) => mockAddAction(...args) }, +})); + +let mockExplainData: unknown[] | undefined = undefined; +let mockExplainIsLoading = false; +let mockExplainError: Error | null = null; + +jest.mock('@/hooks/useExplainQuery', () => ({ + useExplainQuery: () => ({ + data: mockExplainData, + isLoading: mockExplainIsLoading, + error: mockExplainError, + }), +})); + +// Capture the last rendered config so we can assert on it without fighting portals +let lastSQLPreviewConfig: unknown = undefined; +jest.mock('../components/ChartSQLPreview', () => ({ + __esModule: true, + default: (props: { config: unknown }) => { + lastSQLPreviewConfig = props.config; + return
; + }, + SQLPreview: () =>
, +})); + +// Render Mantine Modal content inline (no portal / no transition) so jsdom can see it +jest.mock('@mantine/core', () => { + const actual = jest.requireActual('@mantine/core'); + return { + ...actual, + Modal: ({ opened, onClose, title, children }: any) => + opened ? ( +
+ {title} + + {children} +
+ ) : null, + }; +}); + +// --------------------------------------------------------------------------- +// useSearchTelemetry +// --------------------------------------------------------------------------- + +describe('useSearchTelemetry', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('emits "search executed" action with latency_ms and source_id when a search completes', async () => { + const { result, rerender } = renderHook( + ({ isAnyQueryFetching, isLive, sourceId }) => + useSearchTelemetry({ isAnyQueryFetching, isLive, sourceId }), + { + initialProps: { + isAnyQueryFetching: true, + isLive: false, + sourceId: 'my-source', + }, + }, + ); + + await act(async () => { + rerender({ + isAnyQueryFetching: false, + isLive: false, + sourceId: 'my-source', + }); + }); + + expect(result.current.searchElapsedMs).toBeGreaterThanOrEqual(0); + expect(mockAddAction).toHaveBeenCalledTimes(1); + expect(mockAddAction).toHaveBeenCalledWith('search executed', { + latency_ms: expect.any(Number), + source_id: 'my-source', + }); + }); + + it('does NOT re-emit when sourceId changes after a completed search (P0 fix)', async () => { + const { rerender } = renderHook( + ({ isAnyQueryFetching, isLive, sourceId }) => + useSearchTelemetry({ isAnyQueryFetching, isLive, sourceId }), + { + initialProps: { + isAnyQueryFetching: true, + isLive: false, + sourceId: 'src-a', + }, + }, + ); + + // Complete the search + await act(async () => { + rerender({ isAnyQueryFetching: false, isLive: false, sourceId: 'src-a' }); + }); + + expect(mockAddAction).toHaveBeenCalledTimes(1); + expect(mockAddAction).toHaveBeenCalledWith('search executed', { + latency_ms: expect.any(Number), + source_id: 'src-a', + }); + + // Change sourceId after search is done — must NOT fire again + await act(async () => { + rerender({ isAnyQueryFetching: false, isLive: false, sourceId: 'src-b' }); + }); + + expect(mockAddAction).toHaveBeenCalledTimes(1); // still just 1 + }); + + it('does NOT emit during live-tail ticks (P0 fix)', async () => { + const { rerender } = renderHook( + ({ isAnyQueryFetching, isLive, sourceId }) => + useSearchTelemetry({ isAnyQueryFetching, isLive, sourceId }), + { + initialProps: { + isAnyQueryFetching: true, + isLive: true, + sourceId: 'src', + }, + }, + ); + + // Live-tail cycle completes + await act(async () => { + rerender({ isAnyQueryFetching: false, isLive: true, sourceId: 'src' }); + }); + + expect(mockAddAction).not.toHaveBeenCalled(); + }); + + it('measures elapsed time during live tail for display but does NOT emit', async () => { + const { result, rerender } = renderHook( + ({ isAnyQueryFetching, isLive, sourceId }) => + useSearchTelemetry({ isAnyQueryFetching, isLive, sourceId }), + { + initialProps: { + isAnyQueryFetching: true, + isLive: true, + sourceId: 'src', + }, + }, + ); + + await act(async () => { + rerender({ isAnyQueryFetching: false, isLive: true, sourceId: 'src' }); + }); + + // Latency is surfaced for display ... + expect(result.current.searchElapsedMs).toBeGreaterThanOrEqual(0); + // ... but live-tail cycles are never reported to telemetry. + expect(mockAddAction).not.toHaveBeenCalled(); + }); + + it('keeps the previous elapsed value when a live-tail poll starts (no blink)', async () => { + const { result, rerender } = renderHook( + ({ isAnyQueryFetching, isLive, sourceId }) => + useSearchTelemetry({ isAnyQueryFetching, isLive, sourceId }), + { + initialProps: { + isAnyQueryFetching: true, + isLive: true, + sourceId: 'src', + }, + }, + ); + + await act(async () => { + rerender({ isAnyQueryFetching: false, isLive: true, sourceId: 'src' }); + }); + const firstElapsed = result.current.searchElapsedMs; + expect(firstElapsed).toBeGreaterThanOrEqual(0); + + // Next poll begins — the displayed value must NOT reset to null, otherwise + // the timer would flicker between live-tail refreshes. + await act(async () => { + rerender({ isAnyQueryFetching: true, isLive: true, sourceId: 'src' }); + }); + expect(result.current.searchElapsedMs).toBe(firstElapsed); + }); + + it('does NOT re-stamp the start clock if isAnyQueryFetching is already true (P2 fix)', async () => { + const { result, rerender } = renderHook( + ({ isAnyQueryFetching, isLive, sourceId }) => + useSearchTelemetry({ isAnyQueryFetching, isLive, sourceId }), + { + initialProps: { + isAnyQueryFetching: true, + isLive: false, + sourceId: 'src', + }, + }, + ); + + // Queries temporarily dip to 0 then back up before true completion + await act(async () => { + rerender({ isAnyQueryFetching: false, isLive: false, sourceId: 'src' }); + }); + const firstElapsed = result.current.searchElapsedMs; + + await act(async () => { + rerender({ isAnyQueryFetching: true, isLive: false, sourceId: 'src' }); + }); + // completedSearch is reset to null while re-fetching + expect(result.current.searchElapsedMs).toBeNull(); + + await act(async () => { + rerender({ isAnyQueryFetching: false, isLive: false, sourceId: 'src' }); + }); + + // Two emits (one per false transition) — the clock was re-anchored + // only on the first genuine false→true, so elapsed on both are valid numbers + expect(mockAddAction).toHaveBeenCalledTimes(2); + expect(firstElapsed).toBeGreaterThanOrEqual(0); + expect(result.current.searchElapsedMs).toBeGreaterThanOrEqual(0); + }); + + it('resets elapsed time and does not emit when a new search starts', async () => { + const { result, rerender } = renderHook( + ({ isAnyQueryFetching, isLive, sourceId }) => + useSearchTelemetry({ isAnyQueryFetching, isLive, sourceId }), + { + initialProps: { + isAnyQueryFetching: false, + isLive: false, + sourceId: 'src', + }, + }, + ); + + await act(async () => { + rerender({ isAnyQueryFetching: true, isLive: false, sourceId: 'src' }); + }); + + expect(result.current.searchElapsedMs).toBeNull(); + expect(mockAddAction).not.toHaveBeenCalled(); + }); + + it('falls back to empty string source_id when sourceId is null', async () => { + const { rerender } = renderHook( + ({ isAnyQueryFetching, isLive, sourceId }) => + useSearchTelemetry({ isAnyQueryFetching, isLive, sourceId }), + { + initialProps: { + isAnyQueryFetching: true, + isLive: false, + sourceId: null as string | null, + }, + }, + ); + + await act(async () => { + rerender({ isAnyQueryFetching: false, isLive: false, sourceId: null }); + }); + + expect(mockAddAction).toHaveBeenCalledWith('search executed', { + latency_ms: expect.any(Number), + source_id: '', + }); + }); + + it('does not emit when fetching stops without a prior start', async () => { + const { rerender } = renderHook( + ({ isAnyQueryFetching, isLive, sourceId }) => + useSearchTelemetry({ isAnyQueryFetching, isLive, sourceId }), + { + initialProps: { + isAnyQueryFetching: false, + isLive: false, + sourceId: 'src', + }, + }, + ); + + await act(async () => { + rerender({ isAnyQueryFetching: false, isLive: false, sourceId: 'src' }); + }); + + expect(mockAddAction).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// SearchNumRows +// --------------------------------------------------------------------------- + +const baseConfig = { + source: 'test-source', + dateRange: { from: new Date('2024-01-01'), to: new Date('2024-01-02') }, +} as any; + +describe('SearchNumRows', () => { + beforeEach(() => { + mockExplainData = undefined; + mockExplainIsLoading = false; + mockExplainError = null; + }); + + it('renders nothing when enabled=false', () => { + renderWithMantine( + , + ); + expect(screen.queryByText(/Scanned Rows/)).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /show generated sql/i }), + ).not.toBeInTheDocument(); + }); + + it('shows loading state while explain query is in flight', () => { + mockExplainIsLoading = true; + renderWithMantine( + , + ); + expect(screen.getByText('Scanned Rows ...')).toBeInTheDocument(); + }); + + it('keeps the SQL icon visible while explain is loading (no flicker on poll)', () => { + mockExplainIsLoading = true; + renderWithMantine( + , + ); + expect( + screen.getByRole('button', { name: /show generated sql/i }), + ).toBeInTheDocument(); + }); + + it('renders empty text on error', () => { + mockExplainError = new Error('fail'); + renderWithMantine( + , + ); + // No row count text shown on error + expect(screen.queryByText(/Scanned Rows:/)).not.toBeInTheDocument(); + }); + + it('shows formatted scanned row count when data is present', () => { + mockExplainData = [{ rows: 1482447 }]; + renderWithMantine( + , + ); + expect(screen.getByText('Scanned Rows: 1,482,447')).toBeInTheDocument(); + }); + + it('shows "Elapsed Time: ..." while searching', () => { + mockExplainData = [{ rows: 100 }]; + renderWithMantine( + , + ); + expect(screen.getByText('Elapsed Time: ...')).toBeInTheDocument(); + }); + + it('does not flash the "..." elapsed loading state during live tail', () => { + mockExplainData = [{ rows: 100 }]; + renderWithMantine( + , + ); + // While live and fetching, keep showing the last value, not the "..." + expect(screen.queryByText('Elapsed Time: ...')).not.toBeInTheDocument(); + expect(screen.getByText(/Elapsed Time:/)).toBeInTheDocument(); + }); + + it('hides elapsed during live tail before the first measurement', () => { + mockExplainData = [{ rows: 100 }]; + renderWithMantine( + , + ); + expect(screen.queryByText(/Elapsed Time:/)).not.toBeInTheDocument(); + }); + + it('shows formatted elapsed time after search completes', () => { + mockExplainData = [{ rows: 100 }]; + renderWithMantine( + , + ); + expect(screen.getByText(/Elapsed Time:/)).toBeInTheDocument(); + expect(screen.queryByText('Elapsed Time: ...')).not.toBeInTheDocument(); + }); + + it('hides elapsed time section when there is no elapsed value and not searching', () => { + mockExplainData = [{ rows: 100 }]; + renderWithMantine( + , + ); + expect(screen.queryByText(/Elapsed Time:/)).not.toBeInTheDocument(); + }); + + it('still shows elapsed time and SQL icon when the explain query fails', () => { + mockExplainError = new Error('explain timed out'); + renderWithMantine( + , + ); + // Elapsed time is independent of the explain query and must remain visible + expect(screen.getByText(/Elapsed Time:/)).toBeInTheDocument(); + // SQL preview is also independent of explain data + expect( + screen.getByRole('button', { name: /show generated sql/i }), + ).toBeInTheDocument(); + }); + + it('shows elapsed time while searching even when explain has no data yet', () => { + mockExplainData = undefined; + renderWithMantine( + , + ); + expect(screen.getByText('Elapsed Time: ...')).toBeInTheDocument(); + }); + + it('opens the modal when the SQL button is clicked', async () => { + mockExplainData = [{ rows: 50 }]; + renderWithMantine( + , + ); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /show generated sql/i }), + ); + }); + + expect(screen.getByTestId('modal')).toBeInTheDocument(); + expect(screen.getByTestId('chart-sql-preview')).toBeInTheDocument(); + }); + + it('uses sqlConfig in the modal when provided — title says "Timeline" and content uses sqlConfig', async () => { + mockExplainData = [{ rows: 50 }]; + const sqlConfig = { ...baseConfig, source: 'timeline-source' } as any; + + renderWithMantine( + , + ); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /show generated sql/i }), + ); + }); + + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Generated SQL (Timeline)', + ); + expect(lastSQLPreviewConfig).toEqual(sqlConfig); + }); + + it('uses plain "Generated SQL" title and rows config when no sqlConfig is passed', async () => { + mockExplainData = [{ rows: 50 }]; + const rowsConfig = { ...baseConfig, source: 'rows-source' } as any; + + renderWithMantine( + , + ); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /show generated sql/i }), + ); + }); + + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Generated SQL', + ); + expect( + screen.queryByText('Generated SQL (Timeline)'), + ).not.toBeInTheDocument(); + expect(lastSQLPreviewConfig).toEqual(rowsConfig); + }); +}); diff --git a/packages/app/src/components/ChartSQLPreview.tsx b/packages/app/src/components/ChartSQLPreview.tsx index a07333cf8f..86663577c9 100644 --- a/packages/app/src/components/ChartSQLPreview.tsx +++ b/packages/app/src/components/ChartSQLPreview.tsx @@ -4,6 +4,7 @@ import { format } from '@hyperdx/common-utils/dist/sqlFormatter'; import { ChartConfigWithOptDateRange } from '@hyperdx/common-utils/dist/types'; import { Button, Paper, Text, useMantineColorScheme } from '@mantine/core'; import { IconCheck, IconCopy } from '@tabler/icons-react'; +import { keepPreviousData } from '@tanstack/react-query'; import CodeMirror, { EditorView } from '@uiw/react-codemirror'; import { useRenderedSqlChartConfig } from '@/hooks/useChartConfig'; @@ -97,7 +98,12 @@ export default function ChartSQLPreview({ config: ChartConfigWithOptDateRange; enableCopy?: boolean; }) { - const { data, error, isLoading } = useRenderedSqlChartConfig(config); + const { data, error, isLoading } = useRenderedSqlChartConfig(config, { + // Keep the previously rendered SQL visible while a new one is generated so + // the preview doesn't flicker when the config changes (e.g. live tail + // refreshes the dateRange each poll). + placeholderData: keepPreviousData, + }); return ( - {isLoading ? ( + {data ? ( + // Prefer showing the (possibly placeholder) SQL over the loading state + // so the preview doesn't flicker when the query re-runs — e.g. live tail + // refreshes the dateRange each poll, and builder configs briefly report + // isLoading via the MV-optimization lookup. + + ) : isLoading ? ( Loading query preview... diff --git a/packages/app/src/hooks/useChartConfig.tsx b/packages/app/src/hooks/useChartConfig.tsx index ac2a78c82c..e4393dc780 100644 --- a/packages/app/src/hooks/useChartConfig.tsx +++ b/packages/app/src/hooks/useChartConfig.tsx @@ -460,7 +460,7 @@ export function useQueriedChartConfig( export function useRenderedSqlChartConfig( config: ChartConfigWithOptDateRange, - options?: UseQueryOptions, + options?: Partial>, ) { const { enabled = true } = options ?? {}; diff --git a/packages/app/src/hooks/useExplainQuery.tsx b/packages/app/src/hooks/useExplainQuery.tsx index 0ead671e6f..8e1e5d078d 100644 --- a/packages/app/src/hooks/useExplainQuery.tsx +++ b/packages/app/src/hooks/useExplainQuery.tsx @@ -11,6 +11,8 @@ export function useExplainQuery( _config: ChartConfigWithOptDateRange, options?: Omit, 'queryKey' | 'queryFn'>, ) { + const { enabled, ...restOptions } = options ?? {}; + const config = { ..._config, with: undefined, @@ -42,7 +44,7 @@ export function useExplainQuery( }, retry: false, staleTime: 1000 * 60, - enabled: !isSourceLoading, - ...options, + enabled: enabled && !isSourceLoading, + ...restOptions, }); }