diff --git a/.changeset/heatmap-tile-refresh-pulse.md b/.changeset/heatmap-tile-refresh-pulse.md new file mode 100644 index 0000000000..a2a76ee8de --- /dev/null +++ b/.changeset/heatmap-tile-refresh-pulse.md @@ -0,0 +1,9 @@ +--- +'@hyperdx/app': patch +--- + +fix: keep heatmap tiles on screen while a dashboard refreshes + +During a refresh, heatmap tiles replaced the chart with a "Loading..." message +until the new data arrived. They now keep the current heatmap on screen and +pulse while the refetch runs, like the other dashboard tiles. diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index a752a45db0..02a26f0a48 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import dynamic from 'next/dynamic'; +import cx from 'classnames'; import type { Plugin } from 'uplot'; import uPlot from 'uplot'; import UplotReact from 'uplot-react'; @@ -756,10 +757,12 @@ function HeatmapContainer({ const { data: minMaxData, isLoading: isMinMaxLoading, + isPlaceholderData: isMinMaxPlaceholderData, error: minMaxError, } = useQueriedChartConfig(minMaxConfig, { queryKey: ['heatmap', minMaxConfig], enabled: enabled, + placeholderData: prev => prev, }); // UInt64 are returned as strings; quantile returns floats @@ -781,10 +784,24 @@ function HeatmapContainer({ nBuckets, }); - const { data, isLoading, error } = useQueriedChartConfig(bucketConfig, { - queryKey: ['heatmap_bucket', bucketConfig], - enabled: !!minMaxData && bucketConfig != null && max > effectiveMin, - }); + const canQueryBuckets = + !!minMaxData && bucketConfig != null && max > effectiveMin; + const { data, isLoading, isPlaceholderData, error } = useQueriedChartConfig( + bucketConfig, + { + queryKey: ['heatmap_bucket', bucketConfig], + // Wait for fresh bounds, so a refresh doesn't also query the new range + // bucketed with the previous range's min/max. + enabled: canQueryBuckets && !isMinMaxPlaceholderData, + // Only keep the previous buckets while this query can still run, so an + // empty refreshed range shows "Not enough data points" instead of + // pulsing on stale buckets. + placeholderData: canQueryBuckets ? prev => prev : undefined, + }, + ); + // A refresh keeps the previous heatmap on screen and pulses until both + // queries have fresh data. + const isRefreshing = isMinMaxPlaceholderData || isPlaceholderData; // Memoize so timeBucketByGranularity's fresh Date[] doesn't defeat // the heatmapData memoization downstream. dateRange itself may be a @@ -818,7 +835,21 @@ function HeatmapContainer({ }); }, [data, generatedTsBuckets, scaleType, effectiveMin, max, nBuckets]); - const time = heatmapData[0]; + // While refreshing, keep drawing the last settled heatmap. The previous + // bucket rows only line up with the time buckets, bounds and scale they were + // queried with, so re-plotting them on the new range would draw a blank or + // mis-scaled grid. + const currentView = useMemo( + () => ({ heatmapData, generatedTsBuckets, effectiveMin, scaleType }), + [heatmapData, generatedTsBuckets, effectiveMin, scaleType], + ); + const [settledView, setSettledView] = useState(currentView); + if (!isRefreshing && settledView !== currentView) { + setSettledView(currentView); + } + const view = isRefreshing ? settledView : currentView; + + const time = view.heatmapData[0]; const toolbarItemsMemo = useMemo(() => { const allToolbarItems: React.ReactNode[] = []; @@ -854,15 +885,21 @@ function HeatmapContainer({ ) : _error ? ( - ) : time.length < 2 || generatedTsBuckets?.length < 2 ? ( - + ) : time.length < 2 || view.generatedTsBuckets.length < 2 ? ( + Not enough data points to render heatmap. Try expanding your search criteria. ) : ( @@ -989,6 +1026,7 @@ function highlightDataPlugin({ } function Heatmap({ + className, data, numberFormat, onFilter, @@ -997,6 +1035,7 @@ function Heatmap({ palette, selectionBounds, }: { + className?: string; data: Mode2DataArray; numberFormat?: NumberFormat; onFilter?: (xMin: number, xMax: number, yMin: number, yMax: number) => void; @@ -1299,7 +1338,7 @@ function Heatmap({ return (
{ // Chromium fires a click event on mouseup even after a drag. diff --git a/packages/app/src/components/__tests__/DBHeatmapChart.render.test.tsx b/packages/app/src/components/__tests__/DBHeatmapChart.render.test.tsx new file mode 100644 index 0000000000..203d394154 --- /dev/null +++ b/packages/app/src/components/__tests__/DBHeatmapChart.render.test.tsx @@ -0,0 +1,254 @@ +import { useEffect, useState } from 'react'; +import { DisplayType } from '@hyperdx/common-utils/dist/types'; +import { MantineProvider } from '@mantine/core'; +import { render, screen } from '@testing-library/react'; + +import DBHeatmapChart from '@/components/DBHeatmapChart'; + +type Props = Record; + +// DBHeatmapChart's default export is a `dynamic()` wrapper; render its inner +// component once the loader resolves. +jest.mock('next/dynamic', () => ({ + __esModule: true, + default: (loader: () => Promise>) => { + const Wrapped = (props: Props) => { + const [Component, setComponent] = + useState | null>(null); + useEffect(() => { + let active = true; + loader().then(mod => { + if (active) setComponent(() => mod); + }); + return () => { + active = false; + }; + }, []); + return Component ? : null; + }; + return Wrapped; + }, +})); + +// Heatmap passes uPlot `[[], [time, bucket, count]]` plus its options; record +// what it plots and with which axes. +const mockPlot = jest.fn(); +jest.mock('uplot-react', () => ({ + __esModule: true, + default: ({ + data, + options, + }: { + data: [unknown, number[][]]; + options: { axes?: { splits?: unknown }[] }; + }) => { + mockPlot(data[1], options); + return
; + }, +})); + +const mockUseQueriedChartConfig = jest.fn(); +jest.mock('@/hooks/useChartConfig', () => ({ + useQueriedChartConfig: (...args: unknown[]) => + mockUseQueriedChartConfig(...args), +})); + +const HOUR = 60 * 60 * 1000; +const T0 = new Date('2026-07-06T00:00:00Z').getTime(); + +function configFor(fromMs: number) { + const config: React.ComponentProps['config'] = { + displayType: DisplayType.Heatmap, + select: [{ aggFn: 'heatmap', valueExpression: 'Duration' }], + from: { databaseName: 'default', tableName: 'otel_traces' }, + where: '', + dateRange: [new Date(fromMs), new Date(fromMs + 2 * HOUR)], + granularity: 'auto', + timestampValueExpression: 'Timestamp', + connection: 'test-connection', + }; + return config; +} + +// Bucket rows that line up with the time buckets of `configFor(T0)`. +const bucketData = { + meta: [ + { name: '__hdx_time_bucket', type: 'DateTime' }, + { name: 'x_bucket', type: 'UInt32' }, + { name: 'count', type: 'UInt64' }, + ], + data: [ + { __hdx_time_bucket: new Date(T0).toISOString(), x_bucket: 1, count: '5' }, + { + __hdx_time_bucket: new Date(T0 + HOUR).toISOString(), + x_bucket: 2, + count: '3', + }, + ], +}; + +// The heatmap runs two queries: the bounds (`heatmap`) and the buckets +// (`heatmap_bucket`). Route each call to its own result, the way react-query +// would report it. +function mockQueries({ + boundsPlaceholder = false, + bucketsPlaceholder = false, + bounds = { min: '1', max: '100' }, + buckets = bucketData, +}: { + boundsPlaceholder?: boolean; + bucketsPlaceholder?: boolean; + bounds?: { min: string; max: string }; + buckets?: typeof bucketData | null; +}) { + mockUseQueriedChartConfig.mockImplementation((_config, options) => + options?.queryKey?.includes('heatmap') + ? { + data: { data: [bounds], meta: [] }, + isLoading: false, + isPlaceholderData: boundsPlaceholder, + error: null, + } + : { + data: buckets ?? undefined, + isLoading: false, + isPlaceholderData: bucketsPlaceholder, + error: null, + }, + ); +} + +function lastOptionsFor(queryName: string) { + const calls = mockUseQueriedChartConfig.mock.calls.filter(([, options]) => + options?.queryKey?.includes(queryName), + ); + return calls.at(-1)?.[1]; +} + +const lastPlottedCounts = () => mockPlot.mock.calls.at(-1)?.[0]?.[2]; +// Only the log scale adds custom y-axis splits (ticks at powers of 10). +const lastYAxisIsLog = () => + mockPlot.mock.calls.at(-1)?.[1]?.axes?.[1]?.splits != null; + +// A wrapper (rather than renderWithMantine) keeps the provider in place across +// rerenders, so the chart keeps its state when the range changes. +const renderChart = (fromMs: number) => + render(, { + wrapper: MantineProvider, + }); + +describe('DBHeatmapChart refresh', () => { + beforeEach(() => { + mockUseQueriedChartConfig.mockReset(); + mockPlot.mockReset(); + }); + + it('keeps both queries on their previous data while a refresh loads', async () => { + mockQueries({}); + + renderChart(T0); + await screen.findByTestId('heatmap-plot'); + + const previous = { data: [] }; + expect(lastOptionsFor('heatmap')?.placeholderData?.(previous)).toBe( + previous, + ); + expect(lastOptionsFor('heatmap_bucket')?.placeholderData?.(previous)).toBe( + previous, + ); + }); + + it('keeps plotting the previous heatmap, pulsing, after the range moves', async () => { + mockQueries({}); + const { rerender } = renderChart(T0); + await screen.findByTestId('heatmap-plot'); + const settledCounts = lastPlottedCounts(); + expect(settledCounts).toContain(5); + + // A refresh moves to a later, non-overlapping range; both queries are + // still showing the previous range's results. + mockQueries({ boundsPlaceholder: true, bucketsPlaceholder: true }); + rerender(); + + const plot = await screen.findByTestId('heatmap-plot'); + expect(lastPlottedCounts()).toEqual(settledCounts); + expect(plot.closest('.heatmap-selection-container')).toHaveClass( + 'effect-pulse', + ); + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + }); + + it('keeps the previous scale with the previous heatmap while a scale switch refreshes', async () => { + mockQueries({}); + const { rerender } = renderChart(T0); + await screen.findByTestId('heatmap-plot'); + expect(lastYAxisIsLog()).toBe(true); + const settledCounts = lastPlottedCounts(); + + // Switching to linear refetches both queries; until they return, the + // chart still shows log-scale data, so it must keep the log axis. + mockQueries({ boundsPlaceholder: true, bucketsPlaceholder: true }); + rerender(); + + await screen.findByTestId('heatmap-plot'); + expect(lastPlottedCounts()).toEqual(settledCounts); + expect(lastYAxisIsLog()).toBe(true); + }); + + it('pulses while only the bucket query is still refreshing', async () => { + mockQueries({ bucketsPlaceholder: true }); + + renderChart(T0); + + const plot = await screen.findByTestId('heatmap-plot'); + expect(plot.closest('.heatmap-selection-container')).toHaveClass( + 'effect-pulse', + ); + }); + + it('pulses while only the bounds query is still refreshing', async () => { + mockQueries({ boundsPlaceholder: true }); + + renderChart(T0); + + const plot = await screen.findByTestId('heatmap-plot'); + expect(plot.closest('.heatmap-selection-container')).toHaveClass( + 'effect-pulse', + ); + }); + + it('does not pulse once fresh data has loaded', async () => { + mockQueries({}); + + renderChart(T0); + + const plot = await screen.findByTestId('heatmap-plot'); + expect(plot.closest('.heatmap-selection-container')).not.toHaveClass( + 'effect-pulse', + ); + }); + + it('holds the bucket query until the refreshed bounds arrive', async () => { + mockQueries({ boundsPlaceholder: true, bucketsPlaceholder: true }); + const { rerender } = renderChart(T0); + await screen.findByTestId('heatmap-plot'); + expect(lastOptionsFor('heatmap_bucket')?.enabled).toBe(false); + + mockQueries({ bucketsPlaceholder: true }); + rerender(); + expect(lastOptionsFor('heatmap_bucket')?.enabled).toBe(true); + }); + + it('shows the empty state instead of pulsing when the refreshed range has no data', async () => { + // Fresh bounds of an empty range can't bucket anything, so the bucket + // query must not keep the previous rows as a placeholder. + mockQueries({ bounds: { min: '0', max: '0' }, buckets: null }); + + renderChart(T0); + + const empty = await screen.findByText(/Not enough data points/); + expect(lastOptionsFor('heatmap_bucket')?.placeholderData).toBeUndefined(); + expect(lastOptionsFor('heatmap_bucket')?.enabled).toBe(false); + expect(empty).not.toHaveClass('effect-pulse'); + }); +});