From ec48095fc50be30316b1bcb19496cdc3aeb3a0d8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 11 Apr 2026 01:37:56 +0000 Subject: [PATCH 01/33] feat: wire heatmap chart into dashboard editor and rendering - Add Heatmap tab to chart editor form with IconGrid3x3 icon - Create HeatmapSeriesEditor with value expression (Y-axis) and count expression (intensity) inputs - Add heatmap display type to displayTypeToActiveTab mapping - Add heatmap preview rendering in ChartPreviewPanel - Add heatmap tile rendering in DBDashboardPage - Add heatmap validation: single series, required value expression - Constrain heatmap to single series (no Add Series / ratio) Co-authored-by: Alex Fedotyev --- packages/app/src/DBDashboardPage.tsx | 33 +++++++++ .../app/src/components/ChartEditor/utils.ts | 19 ++++- .../ChartEditorControls.tsx | 15 +++- .../DBEditTimeChartForm/ChartPreviewPanel.tsx | 32 ++++++++ .../DBEditTimeChartForm/EditTimeChartForm.tsx | 20 +++++ .../HeatmapSeriesEditor.tsx | 73 +++++++++++++++++++ .../components/DBEditTimeChartForm/utils.ts | 5 ++ 7 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 packages/app/src/components/DBEditTimeChartForm/HeatmapSeriesEditor.tsx diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 72455363a6..6dd414d030 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -97,6 +97,7 @@ import { } from '@/dashboard'; import ChartContainer from './components/charts/ChartContainer'; +import DBHeatmapChart from './components/DBHeatmapChart'; import { DBPieChart } from './components/DBPieChart'; import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; import OnboardingModal from './components/OnboardingModal'; @@ -596,6 +597,38 @@ const Tile = forwardRef( config={queriedConfig} /> )} + {queriedConfig?.displayType === DisplayType.Heatmap && + isBuilderChartConfig(queriedConfig) && ( + + )?.countExpression + : undefined) as string | undefined, + }, + ], + granularity: 'auto', + }} + /> + )} {effectiveMarkdownConfig?.displayType === DisplayType.Markdown && 'markdown' in effectiveMarkdownConfig && ( diff --git a/packages/app/src/components/ChartEditor/utils.ts b/packages/app/src/components/ChartEditor/utils.ts index d5a28d7851..cbc5e4da31 100644 --- a/packages/app/src/components/ChartEditor/utils.ts +++ b/packages/app/src/components/ChartEditor/utils.ts @@ -246,12 +246,13 @@ export const validateChartForm = ( }); } - // Validate number and pie charts only have one series + // Validate number, pie, and heatmap charts only have one series if ( !isRawSqlChart && Array.isArray(form.series) && (form.displayType === DisplayType.Number || - form.displayType === DisplayType.Pie) && + form.displayType === DisplayType.Pie || + form.displayType === DisplayType.Heatmap) && form.series.length > 1 ) { errors.push({ @@ -260,6 +261,20 @@ export const validateChartForm = ( }); } + // Validate heatmap requires a value expression + if ( + !isRawSqlChart && + form.displayType === DisplayType.Heatmap && + Array.isArray(form.series) && + form.series.length > 0 && + !form.series[0]?.valueExpression + ) { + errors.push({ + path: `series.0.valueExpression`, + message: 'Value expression is required for heatmap charts', + }); + } + for (const error of errors) { console.warn(`Validation error in field ${error.path}: ${error.message}`); setError(error.path, { diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx index 3f075f0a81..900ea2dd8d 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx @@ -29,6 +29,7 @@ import { IS_LOCAL_MODE } from '@/config'; import { DEFAULT_TILE_ALERT } from '@/utils/alerts'; import { ChartSeriesEditor } from './ChartSeriesEditor'; +import { HeatmapSeriesEditor } from './HeatmapSeriesEditor'; import { TileAlertEditor } from './TileAlertEditor'; type ChartEditorControlsProps = { @@ -113,7 +114,16 @@ export function ChartEditorControls({ )} - {displayType !== DisplayType.Search && Array.isArray(select) ? ( + {displayType === DisplayType.Heatmap && Array.isArray(select) ? ( + + ) : displayType !== DisplayType.Search && Array.isArray(select) ? ( <> {fields.map((field, index) => ( {displayType !== DisplayType.Number && - displayType !== DisplayType.Pie && ( + displayType !== DisplayType.Pie && + displayType !== DisplayType.Heatmap && ( + + ) : displayType !== DisplayType.Search && Array.isArray(select) ? ( <> {fields.map((field, index) => ( diff --git a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx index 1ca450e0b9..b294c20053 100644 --- a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx @@ -421,6 +421,7 @@ export default function EditTimeChartForm({ ]; setValue('select', heatmapSeries); setValue('series', heatmapSeries); + setValue('series.0.countExpression' as any, 'count()'); if (traceSource) { setValue('numberFormat', { output: 'duration', @@ -459,6 +460,7 @@ export default function EditTimeChartForm({ ]; setValue('select', heatmapSeries); setValue('series', heatmapSeries); + setValue('series.0.countExpression' as any, 'count()'); setValue('numberFormat', { output: 'duration', factor: 0.001 }); onSubmit(true); } From 58011b35ca4d5b31a93287e03b4f1c0d3161f85d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 11 Apr 2026 20:32:47 +0000 Subject: [PATCH 06/33] refactor: unify Heatmap Settings across search, chart editor, and dashboards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract HeatmapSettingsDrawer into shared component used by both search Event Deltas and chart editor/dashboard contexts - Remove Value/Count fields from the main heatmap editor form — move them into the Heatmap Settings drawer (Scale, Value, Count) - Replace 'Display Settings' button with 'Heatmap Settings' button that opens the same drawer as search Event Deltas gear icon - Pass scaleType (log/linear) from form state through to DBHeatmapChart in preview panel and dashboard tiles - Pre-populate Value with getDurationMsExpression and Count with count() from data source config, editable via Heatmap Settings Co-authored-by: Alex Fedotyev --- packages/app/src/DBDashboardPage.tsx | 9 ++ .../ChartEditorControls.tsx | 27 +--- .../DBEditTimeChartForm/ChartPreviewPanel.tsx | 8 ++ .../HeatmapSeriesEditor.tsx | 131 +++++++++++------- .../src/components/HeatmapSettingsDrawer.tsx | 130 +++++++++++++++++ .../Search/DBSearchHeatmapChart.tsx | 130 +---------------- 6 files changed, 239 insertions(+), 196 deletions(-) create mode 100644 packages/app/src/components/HeatmapSettingsDrawer.tsx diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 8509bd977d..24df9c968b 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -628,6 +628,15 @@ const Tile = forwardRef( granularity: 'auto', numberFormat: queriedConfig.numberFormat, }} + scaleType={ + (Array.isArray(queriedConfig.select) + ? (( + queriedConfig.select[0] as Record + )?.heatmapScaleType as string) + : undefined) === 'linear' + ? 'linear' + : 'log' + } /> )} {effectiveMarkdownConfig?.displayType === diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx index 9e3e6ea5af..e8989d5d2b 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx @@ -115,26 +115,13 @@ export function ChartEditorControls({ {displayType === DisplayType.Heatmap && Array.isArray(select) ? ( - <> - - - - - - + ) : displayType !== DisplayType.Search && Array.isArray(select) ? ( <> {fields.map((field, index) => ( diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx index d2cbb35559..55820f0fa2 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx @@ -189,6 +189,14 @@ export function ChartPreviewPanel({ granularity: 'auto', numberFormat: queriedConfig.numberFormat, }} + scaleType={ + (Array.isArray(queriedConfig.select) + ? ((queriedConfig.select[0] as Record) + ?.heatmapScaleType as string) + : undefined) === 'linear' + ? 'linear' + : 'log' + } /> )} diff --git a/packages/app/src/components/DBEditTimeChartForm/HeatmapSeriesEditor.tsx b/packages/app/src/components/DBEditTimeChartForm/HeatmapSeriesEditor.tsx index 1aadb07b03..d1ea043fc0 100644 --- a/packages/app/src/components/DBEditTimeChartForm/HeatmapSeriesEditor.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/HeatmapSeriesEditor.tsx @@ -1,16 +1,20 @@ -import { Control, UseFormSetValue } from 'react-hook-form'; -import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; -import { TSource } from '@hyperdx/common-utils/dist/types'; -import { Flex, Text } from '@mantine/core'; +import { useCallback, useMemo, useState } from 'react'; +import { Control, UseFormSetValue, useWatch } from 'react-hook-form'; +import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; +import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types'; +import { Button, Divider, Flex } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { ChartEditorFormState } from '@/components/ChartEditor/types'; +import type { HeatmapScaleType } from '@/components/DBHeatmapChart'; +import HeatmapSettingsDrawer, { + HeatmapSettingsValues, +} from '@/components/HeatmapSettingsDrawer'; import SearchWhereInput from '@/components/SearchInput/SearchWhereInput'; -import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor'; type HeatmapSeriesEditorProps = { control: Control; setValue: UseFormSetValue; - tableConnection: TableConnection; tableSource?: TSource; parentRef?: HTMLElement | null; onSubmit: () => void; @@ -19,55 +23,82 @@ type HeatmapSeriesEditorProps = { export function HeatmapSeriesEditor({ control, setValue, - tableConnection, + tableSource, parentRef, onSubmit, }: HeatmapSeriesEditorProps) { + const [settingsOpened, settingsHandlers] = useDisclosure(false); + const [container, setContainer] = useState(null); + + const valueExpression = useWatch({ + control, + name: 'series.0.valueExpression', + }); + const countExpression = useWatch({ + control, + name: 'series.0.countExpression' as any, + }); + + const scaleTypeRaw = useWatch({ + control, + name: 'series.0.heatmapScaleType' as any, + }); + const scaleType: HeatmapScaleType = scaleTypeRaw ?? 'log'; + + const connection = useMemo(() => tcFromSource(tableSource), [tableSource]); + + const handleSettingsSubmit = useCallback( + (data: HeatmapSettingsValues) => { + setValue('series.0.valueExpression', data.value); + setValue('series.0.countExpression' as any, data.count || 'count()'); + onSubmit(); + settingsHandlers.close(); + }, + [setValue, onSubmit, settingsHandlers], + ); + + const handleScaleTypeChange = useCallback( + (v: HeatmapScaleType) => { + setValue('series.0.heatmapScaleType' as any, v); + }, + [setValue], + ); + return ( - -
+ + setValue('whereLanguage', lang) + } + showLabel={false} + /> + + + + + - - Value - - - - Count - - - Where - - setValue('whereLanguage', lang) - } - showLabel={false} - /> -
+ scaleType={scaleType} + onScaleTypeChange={handleScaleTypeChange} + onSubmit={handleSettingsSubmit} + />
); } diff --git a/packages/app/src/components/HeatmapSettingsDrawer.tsx b/packages/app/src/components/HeatmapSettingsDrawer.tsx new file mode 100644 index 0000000000..d297343c50 --- /dev/null +++ b/packages/app/src/components/HeatmapSettingsDrawer.tsx @@ -0,0 +1,130 @@ +import { useCallback } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; +import { + Box, + Button, + Divider, + Drawer, + Group, + SegmentedControl, + Stack, + Text, +} from '@mantine/core'; +import { IconPlayerPlay } from '@tabler/icons-react'; + +import type { HeatmapScaleType } from '@/components/DBHeatmapChart'; +import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor'; + +export const HeatmapSettingsSchema = z.object({ + value: z.string().trim().min(1), + count: z.string().trim().optional(), +}); + +export type HeatmapSettingsValues = z.infer; + +export default function HeatmapSettingsDrawer({ + opened, + onClose, + connection, + parentRef, + defaultValues, + scaleType, + onScaleTypeChange, + onSubmit, +}: { + opened: boolean; + onClose: () => void; + connection: TableConnection; + parentRef?: HTMLElement | null; + defaultValues: HeatmapSettingsValues; + scaleType: HeatmapScaleType; + onScaleTypeChange: (v: HeatmapScaleType) => void; + onSubmit: (v: HeatmapSettingsValues) => void; +}) { + const form = useForm({ + resolver: zodResolver(HeatmapSettingsSchema), + defaultValues, + }); + + const handleClose = useCallback(() => { + form.reset(defaultValues); + onClose(); + }, [onClose, form, defaultValues]); + + return ( + +
+ + + + Scale + + onScaleTypeChange(v as HeatmapScaleType)} + data={[ + { label: 'Log', value: 'log' }, + { label: 'Linear', value: 'linear' }, + ]} + /> + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx index d9ab0d86a2..9a785a8acf 100644 --- a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx +++ b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx @@ -1,12 +1,6 @@ import { useCallback, useState } from 'react'; import { parseAsFloat, parseAsString, useQueryStates } from 'nuqs'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { - TableConnection, - tcFromSource, -} from '@hyperdx/common-utils/dist/core/metadata'; +import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { BuilderChartConfigWithDateRange, DisplayType, @@ -15,21 +9,14 @@ import { import { ActionIcon, Box, - Button, - Divider, - Drawer, Flex, - Group, - SegmentedControl, - Stack, - Text, Tooltip, useMantineColorScheme, } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; -import { IconPlayerPlay, IconSettings } from '@tabler/icons-react'; +import { IconSettings } from '@tabler/icons-react'; -import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor'; +import HeatmapSettingsDrawer from '@/components/HeatmapSettingsDrawer'; import { getDurationMsExpression } from '@/source'; import type { NumberFormat } from '@/types'; @@ -42,11 +29,6 @@ import DBHeatmapChart, { lightPalette, } from '../DBHeatmapChart'; -const Schema = z.object({ - value: z.string().trim().min(1), - count: z.string().trim().optional(), -}); - export function DBSearchHeatmapChart({ chartConfig, source, @@ -157,7 +139,7 @@ export function DBSearchHeatmapChart({ parentRef={container} defaultValues={{ value: fields.value, - count: fields.count, + count: fields.count ?? 'count()', }} scaleType={scaleType} onScaleTypeChange={setScaleType} @@ -190,107 +172,3 @@ export function DBSearchHeatmapChart({ ); } - -function HeatmapSettingsDrawer({ - opened, - onClose, - connection, - parentRef, - defaultValues, - scaleType, - onScaleTypeChange, - onSubmit, -}: { - opened: boolean; - onClose: () => void; - connection: TableConnection; - parentRef?: HTMLElement | null; - defaultValues: z.infer; - scaleType: HeatmapScaleType; - onScaleTypeChange: (v: HeatmapScaleType) => void; - onSubmit: (v: z.infer) => void; -}) { - const form = useForm({ - resolver: zodResolver(Schema), - defaultValues, - }); - - const handleClose = useCallback(() => { - form.reset(defaultValues); - onClose(); - }, [onClose, form, defaultValues]); - - return ( - -
- - - - Scale - - onScaleTypeChange(v as HeatmapScaleType)} - data={[ - { label: 'Log', value: 'log' }, - { label: 'Linear', value: 'linear' }, - ]} - /> - - - - - - - - - - - - - - -
-
- ); -} From a7d7646bda24facb64fd0da3bda868212d005f40 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 15:50:54 +0000 Subject: [PATCH 07/33] fix: reduce uPlot top padding, prevent drawer scroll lock, restore ChartContainer gap - Add padding: [8,0,0,0] to uPlot options to reduce whitespace above the topmost Y-axis tick in heatmap charts - Set lockScroll=false on HeatmapSettingsDrawer to prevent layout shift when opening settings inside a dashboard edit modal - Restore ChartContainer gap to 'xs' (was incorrectly set to 4px affecting all chart types) Co-authored-by: Alex Fedotyev --- .../src/components/DBEditTimeChartForm/EditTimeChartForm.tsx | 4 ++-- packages/app/src/components/DBHeatmapChart.tsx | 1 + packages/app/src/components/HeatmapSettingsDrawer.tsx | 1 + packages/app/src/components/charts/ChartContainer.tsx | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx index b294c20053..8ea62e737b 100644 --- a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx @@ -424,7 +424,7 @@ export default function EditTimeChartForm({ setValue('series.0.countExpression' as any, 'count()'); if (traceSource) { setValue('numberFormat', { - output: 'duration', + output: 'duration' as any, factor: 0.001, }); } @@ -461,7 +461,7 @@ export default function EditTimeChartForm({ setValue('select', heatmapSeries); setValue('series', heatmapSeries); setValue('series.0.countExpression' as any, 'count()'); - setValue('numberFormat', { output: 'duration', factor: 0.001 }); + setValue('numberFormat', { output: 'duration' as any, factor: 0.001 }); onSubmit(true); } }, [sourceId, displayType, tableSource, setValue, onSubmit]); diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index 6f36478725..b3e6011cd8 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -232,6 +232,7 @@ const opt: uPlot.Options = { height: 600, mode: 2, ms: 1, + padding: [8, 0, 0, 0], legend: { show: false, }, diff --git a/packages/app/src/components/HeatmapSettingsDrawer.tsx b/packages/app/src/components/HeatmapSettingsDrawer.tsx index d297343c50..9cd25b9372 100644 --- a/packages/app/src/components/HeatmapSettingsDrawer.tsx +++ b/packages/app/src/components/HeatmapSettingsDrawer.tsx @@ -61,6 +61,7 @@ export default function HeatmapSettingsDrawer({ onClose={handleClose} position="right" size="sm" + lockScroll={false} >
diff --git a/packages/app/src/components/charts/ChartContainer.tsx b/packages/app/src/components/charts/ChartContainer.tsx index e4434efcd0..d81e9bf770 100644 --- a/packages/app/src/components/charts/ChartContainer.tsx +++ b/packages/app/src/components/charts/ChartContainer.tsx @@ -14,7 +14,7 @@ function ChartContainer({ disableReactiveContainer, }: ChartContainerProps) { return ( - + {(!!title || !!toolbarItems?.length) && ( Date: Mon, 13 Apr 2026 16:33:42 +0000 Subject: [PATCH 08/33] fix: prevent layout shift by portaling HeatmapSettingsDrawer, revert ChartContainer changes - Wrap HeatmapSettingsDrawer in Portal so it doesn't participate in the flex layout of the editor form, preventing content shift when opening/closing settings in dashboard edit modal - Revert ChartContainer gap/margin changes to preserve existing spacing for all chart types Co-authored-by: Alex Fedotyev --- .../HeatmapSeriesEditor.tsx | 76 ++++++++++--------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/packages/app/src/components/DBEditTimeChartForm/HeatmapSeriesEditor.tsx b/packages/app/src/components/DBEditTimeChartForm/HeatmapSeriesEditor.tsx index d1ea043fc0..4af8d3310f 100644 --- a/packages/app/src/components/DBEditTimeChartForm/HeatmapSeriesEditor.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/HeatmapSeriesEditor.tsx @@ -1,8 +1,8 @@ import { useCallback, useMemo, useState } from 'react'; import { Control, UseFormSetValue, useWatch } from 'react-hook-form'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; -import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types'; -import { Button, Divider, Flex } from '@mantine/core'; +import { TSource } from '@hyperdx/common-utils/dist/types'; +import { Button, Divider, Flex, Portal } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { ChartEditorFormState } from '@/components/ChartEditor/types'; @@ -65,40 +65,44 @@ export function HeatmapSeriesEditor({ ); return ( - - - setValue('whereLanguage', lang) - } - showLabel={false} - /> - - - + <> + + + setValue('whereLanguage', lang) + } + showLabel={false} + /> + + + + - - + + + + ); } From 1f5b42579dc755081be7bd84c4455225e506b59a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 16:46:44 +0000 Subject: [PATCH 09/33] fix: auto-run chart preview when editing dashboard tiles Pass autoRun to EditTimeChartForm in the dashboard edit modal so saved charts (including heatmaps) render their preview immediately when opened for editing, matching the Chart Explorer behavior. Co-authored-by: Alex Fedotyev --- packages/app/src/DBDashboardPage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 24df9c968b..a23e74af8a 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -829,6 +829,7 @@ const EditTileModal = ({ onClose={handleClose} onDirtyChange={setHasUnsavedChanges} isDashboardForm + autoRun /> )} From 09854eb0b5e206a1f573c6ecb4701040880434c8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 16:47:39 +0000 Subject: [PATCH 10/33] =?UTF-8?q?rename:=20Heatmap=20Settings=20=E2=86=92?= =?UTF-8?q?=20Display=20Settings=20for=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Alex Fedotyev --- .../src/components/DBEditTimeChartForm/HeatmapSeriesEditor.tsx | 2 +- packages/app/src/components/HeatmapSettingsDrawer.tsx | 2 +- packages/app/src/components/Search/DBSearchHeatmapChart.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/DBEditTimeChartForm/HeatmapSeriesEditor.tsx b/packages/app/src/components/DBEditTimeChartForm/HeatmapSeriesEditor.tsx index 4af8d3310f..96e0f6c295 100644 --- a/packages/app/src/components/DBEditTimeChartForm/HeatmapSeriesEditor.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/HeatmapSeriesEditor.tsx @@ -84,7 +84,7 @@ export function HeatmapSeriesEditor({ size="compact-sm" variant="secondary" > - Heatmap Settings + Display Settings diff --git a/packages/app/src/components/HeatmapSettingsDrawer.tsx b/packages/app/src/components/HeatmapSettingsDrawer.tsx index 9cd25b9372..9bf93d5361 100644 --- a/packages/app/src/components/HeatmapSettingsDrawer.tsx +++ b/packages/app/src/components/HeatmapSettingsDrawer.tsx @@ -56,7 +56,7 @@ export default function HeatmapSettingsDrawer({ return ( {/* Gear icon overlaid on chart top-right */} - + Date: Mon, 13 Apr 2026 16:56:07 +0000 Subject: [PATCH 11/33] fix: lift HeatmapSettingsDrawer to EditTimeChartForm level to prevent layout shift Move the HeatmapSettingsDrawer out of HeatmapSeriesEditor and render it at the EditTimeChartForm top level (same pattern as ChartDisplaySettingsDrawer). This prevents any DOM insertion from affecting the flex layout between the settings button and run button when the drawer opens/closes. Co-authored-by: Alex Fedotyev --- .../ChartEditorControls.tsx | 4 +- .../DBEditTimeChartForm/EditTimeChartForm.tsx | 54 +++++++++ .../HeatmapSeriesEditor.tsx | 109 +++++------------- 3 files changed, 83 insertions(+), 84 deletions(-) diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx index e8989d5d2b..acb2a9b70a 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx @@ -57,6 +57,7 @@ type ChartEditorControlsProps = { chartConfigForExplanations?: ChartConfigWithOptTimestamp; onSubmit: (suppressErrorNotification?: boolean) => void; openDisplaySettings: () => void; + openHeatmapSettings?: () => void; }; export function ChartEditorControls({ @@ -84,6 +85,7 @@ export function ChartEditorControls({ chartConfigForExplanations, onSubmit, openDisplaySettings, + openHeatmapSettings, }: ChartEditorControlsProps) { return ( <> @@ -119,8 +121,8 @@ export function ChartEditorControls({ control={control} setValue={setValue} tableSource={tableSource} - parentRef={parentRef} onSubmit={onSubmit} + onOpenDisplaySettings={openHeatmapSettings ?? openDisplaySettings} /> ) : displayType !== DisplayType.Search && Array.isArray(select) ? ( <> diff --git a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx index 8ea62e737b..5ee6e68f8f 100644 --- a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx @@ -47,7 +47,11 @@ import { isRawSqlDisplayType, validateChartForm, } from '@/components/ChartEditor/utils'; +import type { HeatmapScaleType } from '@/components/DBHeatmapChart'; import { ErrorBoundary } from '@/components/Error/ErrorBoundary'; +import HeatmapSettingsDrawer, { + HeatmapSettingsValues, +} from '@/components/HeatmapSettingsDrawer'; import { InputControlled } from '@/components/InputControlled'; import SaveToDashboardModal from '@/components/SaveToDashboardModal'; import { getStoredLanguage } from '@/components/SearchInput/SearchWhereInput'; @@ -223,6 +227,11 @@ export default function EditTimeChartForm({ { open: openDisplaySettings, close: closeDisplaySettings }, ] = useDisclosure(false); + const [ + heatmapSettingsOpened, + { open: openHeatmapSettings, close: closeHeatmapSettings }, + ] = useDisclosure(false); + // Only update this on submit, otherwise we'll have issues // with using the source value from the last submit // (ex. ignoring local custom source updates) @@ -524,6 +533,37 @@ export default function EditTimeChartForm({ [setValue, onSubmit], ); + const handleUpdateHeatmapSettings = useCallback( + (data: HeatmapSettingsValues) => { + setValue('series.0.valueExpression', data.value); + setValue('series.0.countExpression' as any, data.count || 'count()'); + onSubmit(); + closeHeatmapSettings(); + }, + [setValue, onSubmit, closeHeatmapSettings], + ); + + const handleHeatmapScaleTypeChange = useCallback( + (v: HeatmapScaleType) => { + setValue('series.0.heatmapScaleType' as any, v); + }, + [setValue], + ); + + const heatmapValueExpression = useWatch({ + control, + name: 'series.0.valueExpression', + }); + const heatmapCountExpression = useWatch({ + control, + name: 'series.0.countExpression' as any, + }); + const heatmapScaleTypeRaw = useWatch({ + control, + name: 'series.0.heatmapScaleType' as any, + }); + const heatmapScaleType: HeatmapScaleType = heatmapScaleTypeRaw ?? 'log'; + const tableConnection = useMemo( () => tcFromSource(tableSource), [tableSource], @@ -674,6 +714,7 @@ export default function EditTimeChartForm({ chartConfigForExplanations={chartConfigForExplanations} onSubmit={onSubmit} openDisplaySettings={openDisplaySettings} + openHeatmapSettings={openHeatmapSettings} /> )} + ); } diff --git a/packages/app/src/components/DBEditTimeChartForm/HeatmapSeriesEditor.tsx b/packages/app/src/components/DBEditTimeChartForm/HeatmapSeriesEditor.tsx index 96e0f6c295..57d03a2091 100644 --- a/packages/app/src/components/DBEditTimeChartForm/HeatmapSeriesEditor.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/HeatmapSeriesEditor.tsx @@ -1,108 +1,51 @@ -import { useCallback, useMemo, useState } from 'react'; -import { Control, UseFormSetValue, useWatch } from 'react-hook-form'; +import { useMemo } from 'react'; +import { Control, UseFormSetValue } from 'react-hook-form'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { TSource } from '@hyperdx/common-utils/dist/types'; -import { Button, Divider, Flex, Portal } from '@mantine/core'; -import { useDisclosure } from '@mantine/hooks'; +import { Button, Divider, Flex } from '@mantine/core'; import { ChartEditorFormState } from '@/components/ChartEditor/types'; -import type { HeatmapScaleType } from '@/components/DBHeatmapChart'; -import HeatmapSettingsDrawer, { - HeatmapSettingsValues, -} from '@/components/HeatmapSettingsDrawer'; import SearchWhereInput from '@/components/SearchInput/SearchWhereInput'; type HeatmapSeriesEditorProps = { control: Control; setValue: UseFormSetValue; tableSource?: TSource; - parentRef?: HTMLElement | null; onSubmit: () => void; + onOpenDisplaySettings: () => void; }; export function HeatmapSeriesEditor({ control, setValue, tableSource, - parentRef, onSubmit, + onOpenDisplaySettings, }: HeatmapSeriesEditorProps) { - const [settingsOpened, settingsHandlers] = useDisclosure(false); - const [container, setContainer] = useState(null); - - const valueExpression = useWatch({ - control, - name: 'series.0.valueExpression', - }); - const countExpression = useWatch({ - control, - name: 'series.0.countExpression' as any, - }); - - const scaleTypeRaw = useWatch({ - control, - name: 'series.0.heatmapScaleType' as any, - }); - const scaleType: HeatmapScaleType = scaleTypeRaw ?? 'log'; - const connection = useMemo(() => tcFromSource(tableSource), [tableSource]); - const handleSettingsSubmit = useCallback( - (data: HeatmapSettingsValues) => { - setValue('series.0.valueExpression', data.value); - setValue('series.0.countExpression' as any, data.count || 'count()'); - onSubmit(); - settingsHandlers.close(); - }, - [setValue, onSubmit, settingsHandlers], - ); - - const handleScaleTypeChange = useCallback( - (v: HeatmapScaleType) => { - setValue('series.0.heatmapScaleType' as any, v); - }, - [setValue], - ); - return ( - <> - - - setValue('whereLanguage', lang) - } - showLabel={false} - /> - - - - + + + setValue('whereLanguage', lang) + } + showLabel={false} + /> + + + - - - - + ); } From 1895ceeca6361f8eb4417cd220223e6eb206ce91 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 18:43:36 +0000 Subject: [PATCH 12/33] =?UTF-8?q?refactor:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20extract=20toHeatmapChartConfig,=20fix=20stale=20for?= =?UTF-8?q?m,=20revert=20ChartContainer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract toHeatmapChartConfig() helper into DBHeatmapChart.tsx, eliminating duplicated config construction and unsafe casts in DBDashboardPage and ChartPreviewPanel - Export HeatmapChartConfig and HeatmapSelectExtras types for typed access to countExpression and heatmapScaleType on select items - Add useEffect to sync HeatmapSettingsDrawer form when defaultValues change (prevents stale closure when source changes while drawer open) - Fully revert ChartContainer to original (no gap change) Co-authored-by: Alex Fedotyev --- packages/app/src/DBDashboardPage.tsx | 59 ++++++------------- .../DBEditTimeChartForm/ChartPreviewPanel.tsx | 53 +++++------------ .../app/src/components/DBHeatmapChart.tsx | 39 +++++++++++- .../src/components/HeatmapSettingsDrawer.tsx | 6 +- .../src/components/charts/ChartContainer.tsx | 2 +- 5 files changed, 77 insertions(+), 82 deletions(-) diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index a23e74af8a..dcaf156dc5 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -97,7 +97,9 @@ import { } from '@/dashboard'; import ChartContainer from './components/charts/ChartContainer'; -import DBHeatmapChart from './components/DBHeatmapChart'; +import DBHeatmapChart, { + toHeatmapChartConfig, +} from './components/DBHeatmapChart'; import { DBPieChart } from './components/DBPieChart'; import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; import OnboardingModal from './components/OnboardingModal'; @@ -598,47 +600,20 @@ const Tile = forwardRef( /> )} {queriedConfig?.displayType === DisplayType.Heatmap && - isBuilderChartConfig(queriedConfig) && ( - - )?.countExpression - : undefined) as string | undefined, - }, - ], - granularity: 'auto', - numberFormat: queriedConfig.numberFormat, - }} - scaleType={ - (Array.isArray(queriedConfig.select) - ? (( - queriedConfig.select[0] as Record - )?.heatmapScaleType as string) - : undefined) === 'linear' - ? 'linear' - : 'log' - } - /> - )} + isBuilderChartConfig(queriedConfig) && + (() => { + const { heatmapConfig, scaleType } = + toHeatmapChartConfig(queriedConfig); + return ( + + ); + })()} {effectiveMarkdownConfig?.displayType === DisplayType.Markdown && 'markdown' in effectiveMarkdownConfig && ( diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx index 55820f0fa2..f1fe4f09a9 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx @@ -15,7 +15,9 @@ import { buildTableRowSearchUrl } from '@/ChartUtils'; import { getAlertReferenceLines } from '@/components/Alerts'; import { ChartEditorFormState } from '@/components/ChartEditor/types'; import ChartSQLPreview from '@/components/ChartSQLPreview'; -import DBHeatmapChart from '@/components/DBHeatmapChart'; +import DBHeatmapChart, { + toHeatmapChartConfig, +} from '@/components/DBHeatmapChart'; import DBNumberChart from '@/components/DBNumberChart'; import { DBPieChart } from '@/components/DBPieChart'; import DBSqlRowTableWithSideBar from '@/components/DBSqlRowTableWithSidebar'; @@ -164,42 +166,19 @@ export function ChartPreviewPanel({ {queryReady && queriedConfig != null && isBuilderChartConfig(queriedConfig) && - activeTab === 'heatmap' && ( -
- ) - ?.countExpression - : undefined) as string | undefined, - }, - ], - granularity: 'auto', - numberFormat: queriedConfig.numberFormat, - }} - scaleType={ - (Array.isArray(queriedConfig.select) - ? ((queriedConfig.select[0] as Record) - ?.heatmapScaleType as string) - : undefined) === 'linear' - ? 'linear' - : 'log' - } - /> -
- )} + activeTab === 'heatmap' && + (() => { + const { heatmapConfig, scaleType } = + toHeatmapChartConfig(queriedConfig); + return ( +
+ +
+ ); + })()} {queryReady && tableSource && queriedConfig != null && diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index b3e6011cd8..7aa13459d2 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -285,7 +285,7 @@ function buildSeriesForPalette(colors: string[]): Partial { }; } -type HeatmapChartConfig = { +export type HeatmapChartConfig = { displayType: DisplayType.Heatmap; select: [ { @@ -305,6 +305,43 @@ type HeatmapChartConfig = { with?: BuilderChartConfigWithDateRange['with']; }; +/** + * Extra fields stored on heatmap select items alongside the standard + * DerivedColumn schema. These aren't part of the Zod schema but are + * preserved through MongoDB and the form state. + */ +export type HeatmapSelectExtras = { + countExpression?: string; + heatmapScaleType?: HeatmapScaleType; +}; + +/** Build a HeatmapChartConfig from a builder chart config that has heatmap extras on select[0]. */ +export function toHeatmapChartConfig(config: BuilderChartConfigWithDateRange): { + heatmapConfig: HeatmapChartConfig; + scaleType: HeatmapScaleType; +} { + const firstSelect = Array.isArray(config.select) + ? config.select[0] + : undefined; + const extras = (firstSelect ?? {}) as HeatmapSelectExtras; + return { + heatmapConfig: { + ...config, + displayType: DisplayType.Heatmap, + select: [ + { + aggFn: 'heatmap' as const, + valueExpression: firstSelect?.valueExpression ?? '', + countExpression: extras.countExpression, + }, + ], + granularity: 'auto', + numberFormat: config.numberFormat, + }, + scaleType: extras.heatmapScaleType === 'linear' ? 'linear' : 'log', + }; +} + export function ColorLegend({ colors }: { colors: string[] }) { return ( { + form.reset(defaultValues); + }, [defaultValues, form]); + const handleClose = useCallback(() => { form.reset(defaultValues); onClose(); diff --git a/packages/app/src/components/charts/ChartContainer.tsx b/packages/app/src/components/charts/ChartContainer.tsx index d81e9bf770..d3fb25afdd 100644 --- a/packages/app/src/components/charts/ChartContainer.tsx +++ b/packages/app/src/components/charts/ChartContainer.tsx @@ -14,7 +14,7 @@ function ChartContainer({ disableReactiveContainer, }: ChartContainerProps) { return ( - + {(!!title || !!toolbarItems?.length) && ( Date: Mon, 13 Apr 2026 18:54:06 +0000 Subject: [PATCH 13/33] fix: remove unused HeatmapSelectExtras export, memoize defaultValues - Remove export from HeatmapSelectExtras (fixes knip unused export) - Memoize heatmapSettingsDefaults in EditTimeChartForm and DBSearchHeatmapChart to prevent form.reset on every parent render Co-authored-by: Alex Fedotyev --- .../DBEditTimeChartForm/EditTimeChartForm.tsx | 13 +++++++++---- packages/app/src/components/DBHeatmapChart.tsx | 2 +- .../components/Search/DBSearchHeatmapChart.tsx | 15 ++++++++++----- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx index 174dfe8213..1bed1fefb3 100644 --- a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx @@ -569,6 +569,14 @@ export default function EditTimeChartForm({ }); const heatmapScaleType: HeatmapScaleType = heatmapScaleTypeRaw ?? 'log'; + const heatmapSettingsDefaults = useMemo( + () => ({ + value: heatmapValueExpression || '', + count: heatmapCountExpression || 'count()', + }), + [heatmapValueExpression, heatmapCountExpression], + ); + const tableConnection = useMemo( () => tcFromSource(tableSource), [tableSource], @@ -778,10 +786,7 @@ export default function EditTimeChartForm({ onClose={closeHeatmapSettings} connection={tableConnection} parentRef={parentRef} - defaultValues={{ - value: heatmapValueExpression || '', - count: heatmapCountExpression || 'count()', - }} + defaultValues={heatmapSettingsDefaults} scaleType={heatmapScaleType} onScaleTypeChange={handleHeatmapScaleTypeChange} onSubmit={handleUpdateHeatmapSettings} diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index 7aa13459d2..85cb415aa5 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -310,7 +310,7 @@ export type HeatmapChartConfig = { * DerivedColumn schema. These aren't part of the Zod schema but are * preserved through MongoDB and the form state. */ -export type HeatmapSelectExtras = { +type HeatmapSelectExtras = { countExpression?: string; heatmapScaleType?: HeatmapScaleType; }; diff --git a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx index f81c693323..01ae937886 100644 --- a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx +++ b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { parseAsFloat, parseAsString, useQueryStates } from 'nuqs'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { @@ -62,6 +62,14 @@ export function DBSearchHeatmapChart({ const { colorScheme } = useMantineColorScheme(); const palette = colorScheme === 'light' ? lightPalette : darkPalette; + const heatmapSettingsDefaults = useMemo( + () => ({ + value: fields.value, + count: fields.count ?? 'count()', + }), + [fields.value, fields.count], + ); + // After applying a filter, clear the heatmap selection so the delta chart // resets instead of staying in comparison mode. const handleAddFilterAndClearSelection = useCallback< @@ -137,10 +145,7 @@ export function DBSearchHeatmapChart({ onClose={settingsHandlers.close} connection={tcFromSource(source)} parentRef={container} - defaultValues={{ - value: fields.value, - count: fields.count ?? 'count()', - }} + defaultValues={heatmapSettingsDefaults} scaleType={scaleType} onScaleTypeChange={setScaleType} onSubmit={data => { From b392516b858cf63c43e187d353977d7d14a906bb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 19:05:06 +0000 Subject: [PATCH 14/33] fix: widen Y-axis and add right padding to prevent label clipping - Increase Y-axis size from 60px to 70px so labels like '1000ms' and '.67min' are not truncated - Add 8px right padding so X-axis labels at the right edge are not clipped by the container overflow Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBHeatmapChart.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index 85cb415aa5..5f7ed8a802 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -232,7 +232,7 @@ const opt: uPlot.Options = { height: 600, mode: 2, ms: 1, - padding: [8, 0, 0, 0], + padding: [8, 8, 0, 0], legend: { show: false, }, @@ -247,7 +247,7 @@ const opt: uPlot.Options = { }, { ...axis, - size: 60, // fixed width so labels like "600ms" and "100µs" fit + size: 70, // fixed width so labels like "1000ms" and ".67min" fit }, ], series: [ From 78c26de641411824f2f5c65041d898006b7e5d06 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 19:30:14 +0000 Subject: [PATCH 15/33] fix: dynamic Y-axis sizing and compact tick labels - Replace fixed Y-axis size with dynamic measurement that uses canvas measureText on the actual formatted tick labels + padding - Add formatDurationMsCompact for axis ticks: shorter units ('m' instead of 'min'), values under 2min stay as seconds (no '.67min'), fewer decimal places throughout - Full formatDurationMs still used in tooltips via the same formatter Co-authored-by: Alex Fedotyev --- .../app/src/components/DBHeatmapChart.tsx | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index 5f7ed8a802..afc17b9cdc 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -33,6 +33,21 @@ import { formatDurationMs, formatNumber } from '@/utils'; import ChartContainer from './charts/ChartContainer'; import { SQLPreview } from './ChartSQLPreview'; +/** Compact duration labels for axis ticks — fewer decimals, shorter units. */ +function formatDurationMsCompact(ms: number): string { + if (ms < 0) return `-${formatDurationMsCompact(-ms)}`; + if (ms === 0) return '0'; + if (ms < 0.001) return `${+(ms * 1e6).toPrecision(2)}ns`; + if (ms < 1) { + const µs = ms * 1000; + return µs < 10 ? `${+µs.toPrecision(2)}µs` : `${Math.round(µs)}µs`; + } + if (ms < 1000) return ms < 10 ? `${+ms.toPrecision(2)}ms` : `${Math.round(ms)}ms`; + if (ms < 120_000) return `${+(ms / 1000).toPrecision(3)}s`; + if (ms < 3_600_000) return `${+(ms / 60_000).toPrecision(2)}m`; + return `${+(ms / 3_600_000).toPrecision(2)}h`; +} + type Mode2DataArray = [number[], number[], number[]]; // From: https://github.com/leeoniya/uPlot/blob/a4edb297a9b80baf781f4d05a40fb52fae737bff/demos/latency-heatmap.html#L436 @@ -247,7 +262,22 @@ const opt: uPlot.Options = { }, { ...axis, - size: 70, // fixed width so labels like "1000ms" and ".67min" fit + // Dynamic size: measure the widest formatted tick label + padding. + // Falls back to 50 when no values are available yet. + size(self, values) { + if (!values || values.length === 0) return 50; + const font = self.axes[1]?.font ?? '12px IBM Plex Mono, monospace'; + const ctx = self.ctx; + ctx.save(); + ctx.font = font; + let maxW = 0; + for (const v of values) { + const w = ctx.measureText(v).width; + if (w > maxW) maxW = w; + } + ctx.restore(); + return Math.ceil(maxW) + 16; + }, }, ], series: [ @@ -886,7 +916,7 @@ function Heatmap({ numberFormat?.output === 'duration' ? actualValue * (numberFormat?.factor ?? 1) * 1000 : actualValue; - return formatDurationMs(msValue); + return formatDurationMsCompact(msValue); } return numberFormat From d68bb1a3eb3c029dad4a34ea9097bab9d09b501e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 19:38:18 +0000 Subject: [PATCH 16/33] fix: prettier formatting in formatDurationMsCompact Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBHeatmapChart.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index afc17b9cdc..fb4f188600 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -42,7 +42,9 @@ function formatDurationMsCompact(ms: number): string { const µs = ms * 1000; return µs < 10 ? `${+µs.toPrecision(2)}µs` : `${Math.round(µs)}µs`; } - if (ms < 1000) return ms < 10 ? `${+ms.toPrecision(2)}ms` : `${Math.round(ms)}ms`; + if (ms < 1000) { + return ms < 10 ? `${+ms.toPrecision(2)}ms` : `${Math.round(ms)}ms`; + } if (ms < 120_000) return `${+(ms / 1000).toPrecision(3)}s`; if (ms < 3_600_000) return `${+(ms / 60_000).toPrecision(2)}m`; return `${+(ms / 3_600_000).toPrecision(2)}h`; From 9772d1f7780646f190536821d17dc611e7ff6700 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 19:53:42 +0000 Subject: [PATCH 17/33] fix: X-axis label overlap, replace IIFEs with components - Add gap=8 to X-axis to prevent tick labels from overlapping - Replace IIFE patterns with HeatmapTile and HeatmapPreview components in DBDashboardPage and ChartPreviewPanel Co-authored-by: Alex Fedotyev --- packages/app/src/DBDashboardPage.tsx | 49 +++++++++++++------ .../DBEditTimeChartForm/ChartPreviewPanel.tsx | 28 ++++++----- .../app/src/components/DBHeatmapChart.tsx | 1 + 3 files changed, 51 insertions(+), 27 deletions(-) diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 9ba9e2f058..e29629436d 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -32,6 +32,7 @@ import { } from '@hyperdx/common-utils/dist/guards'; import { AlertState, + BuilderChartConfigWithDateRange, ChartConfigWithDateRange, DashboardContainer, DashboardFilter, @@ -138,6 +139,31 @@ import { useZIndex, ZIndexContext } from './zIndex'; import 'react-grid-layout/css/styles.css'; import 'react-resizable/css/styles.css'; +function HeatmapTile({ + keyPrefix, + chartId, + title, + toolbar, + queriedConfig, +}: { + keyPrefix: string; + chartId: string; + title: React.ReactNode; + toolbar: React.ReactNode[]; + queriedConfig: BuilderChartConfigWithDateRange; +}) { + const { heatmapConfig, scaleType } = toHeatmapChartConfig(queriedConfig); + return ( + + ); +} + const makeId = () => Math.floor(100000000 * Math.random()).toString(36); const ReactGridLayout = WidthProvider(RGL); @@ -603,20 +629,15 @@ const Tile = forwardRef( /> )} {queriedConfig?.displayType === DisplayType.Heatmap && - isBuilderChartConfig(queriedConfig) && - (() => { - const { heatmapConfig, scaleType } = - toHeatmapChartConfig(queriedConfig); - return ( - - ); - })()} + isBuilderChartConfig(queriedConfig) && ( + + )} {effectiveMarkdownConfig?.displayType === DisplayType.Markdown && 'markdown' in effectiveMarkdownConfig && ( diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx index f1fe4f09a9..48cd4d81cb 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo, useState } from 'react'; import { isBuilderChartConfig } from '@hyperdx/common-utils/dist/guards'; import { + BuilderChartConfigWithDateRange, ChartConfigWithDateRange, ChartConfigWithOptTimestamp, DisplayType, @@ -32,6 +33,19 @@ import { import { buildSampleEventsConfig, isQueryReady } from './utils'; +function HeatmapPreview({ + config, +}: { + config: BuilderChartConfigWithDateRange; +}) { + const { heatmapConfig, scaleType } = toHeatmapChartConfig(config); + return ( +
+ +
+ ); +} + type ChartPreviewPanelProps = { queriedConfig?: ChartConfigWithDateRange; tableSource?: TSource; @@ -166,19 +180,7 @@ export function ChartPreviewPanel({ {queryReady && queriedConfig != null && isBuilderChartConfig(queriedConfig) && - activeTab === 'heatmap' && - (() => { - const { heatmapConfig, scaleType } = - toHeatmapChartConfig(queriedConfig); - return ( -
- -
- ); - })()} + activeTab === 'heatmap' && } {queryReady && tableSource && queriedConfig != null && diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index fb4f188600..0e1f8c71f8 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -261,6 +261,7 @@ const opt: uPlot.Options = { axes: [ { ...axis, + gap: 8, }, { ...axis, From 2721fb6f3a3e624243806331e557907a9a919aae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 22:17:02 +0000 Subject: [PATCH 18/33] fix: increase X-axis min tick spacing to prevent cramped labels in small containers Set space=60 on the X-axis so uPlot allocates at least 60px per tick interval. This prevents labels from being packed together in narrow contexts like the search heatmap (260px height). The fix is in the shared opt constant used by all heatmap renderings. Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBHeatmapChart.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index 0e1f8c71f8..dbecb0d322 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -261,7 +261,8 @@ const opt: uPlot.Options = { axes: [ { ...axis, - gap: 8, + gap: 10, + space: 60, }, { ...axis, From badd8d53bf81d4f6561e725a5a3ccf2866892323 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 22:32:19 +0000 Subject: [PATCH 19/33] fix: add 4px left padding to prevent Y-axis label clipping Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBHeatmapChart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index dbecb0d322..02783d42e7 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -249,7 +249,7 @@ const opt: uPlot.Options = { height: 600, mode: 2, ms: 1, - padding: [8, 8, 0, 0], + padding: [8, 8, 0, 4], legend: { show: false, }, From 58fd3681094e6470cd29489fc607c2eb2ae565ef Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Apr 2026 00:11:50 +0000 Subject: [PATCH 20/33] fix: add left padding to search heatmap container to prevent Y-axis clipping Co-authored-by: Alex Fedotyev --- packages/app/src/components/Search/DBSearchHeatmapChart.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx index 01ae937886..d6317be666 100644 --- a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx +++ b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx @@ -95,6 +95,7 @@ export function DBSearchHeatmapChart({ maxHeight: 260, width: '100%', position: 'relative', + paddingLeft: 4, }} > Date: Tue, 14 Apr 2026 00:34:32 +0000 Subject: [PATCH 21/33] fix: add right padding to search heatmap container to match left Co-authored-by: Alex Fedotyev --- packages/app/src/components/Search/DBSearchHeatmapChart.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx index d6317be666..ee099ef987 100644 --- a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx +++ b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx @@ -96,6 +96,7 @@ export function DBSearchHeatmapChart({ width: '100%', position: 'relative', paddingLeft: 4, + paddingRight: 4, }} > Date: Tue, 14 Apr 2026 01:03:09 +0000 Subject: [PATCH 22/33] fix: hide Generated SQL for heatmap, clean up useEffect deps - Remove 'heatmap' from TABS_WITH_GENERATED_SQL since the heatmap builds its own queries internally (min/max + bucket), so the Generated SQL section would show misleading SQL - Remove stable form object from useEffect dependency array in HeatmapSettingsDrawer Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBEditTimeChartForm/utils.ts | 1 - packages/app/src/components/HeatmapSettingsDrawer.tsx | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/DBEditTimeChartForm/utils.ts b/packages/app/src/components/DBEditTimeChartForm/utils.ts index 083cc7e0dd..59bbf90a32 100644 --- a/packages/app/src/components/DBEditTimeChartForm/utils.ts +++ b/packages/app/src/components/DBEditTimeChartForm/utils.ts @@ -102,7 +102,6 @@ export const TABS_WITH_GENERATED_SQL = new Set([ 'time', 'number', 'pie', - 'heatmap', ]); export function computeDbTimeChartConfig( diff --git a/packages/app/src/components/HeatmapSettingsDrawer.tsx b/packages/app/src/components/HeatmapSettingsDrawer.tsx index 154d1d45f3..d3b6a429c1 100644 --- a/packages/app/src/components/HeatmapSettingsDrawer.tsx +++ b/packages/app/src/components/HeatmapSettingsDrawer.tsx @@ -51,7 +51,8 @@ export default function HeatmapSettingsDrawer({ useEffect(() => { form.reset(defaultValues); - }, [defaultValues, form]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- form object is stable from useForm + }, [defaultValues]); const handleClose = useCallback(() => { form.reset(defaultValues); From a587ca25f7bd2c7fc0d637cbe2c2f57736c9552b Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Tue, 14 Apr 2026 19:01:25 -0700 Subject: [PATCH 23/33] fix: restrict heatmap data source picker to trace sources only Heatmap charts require trace data (duration expression) to work properly. Non-trace sources would error out with no guidance. Instead of adding complex error messaging, filter the source dropdown to only show trace sources when heatmap display type is selected. --- .../ChartEditorControls.tsx | 5 +++++ .../DBEditTimeChartForm/EditTimeChartForm.tsx | 19 +++++++------------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx index acb2a9b70a..9cc739838a 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx @@ -99,6 +99,11 @@ export function ChartEditorControls({ control={control} name="source" data-testid="source-selector" + allowedSourceKinds={ + displayType === DisplayType.Heatmap + ? [SourceKind.Trace] + : undefined + } sourceSchemaPreview={ } diff --git a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx index 1bed1fefb3..6b35a513ab 100644 --- a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx @@ -417,14 +417,11 @@ export default function EditTimeChartForm({ } if (displayType === DisplayType.Heatmap && Array.isArray(select)) { - const traceSource = + const defaultValue = tableSource?.kind === SourceKind.Trace && tableSource.durationExpression - ? tableSource - : undefined; - const defaultValue = traceSource - ? getDurationMsExpression(traceSource) - : (select[0]?.valueExpression ?? ''); + ? getDurationMsExpression(tableSource) + : (select[0]?.valueExpression ?? ''); const heatmapSeries: SavedChartConfigWithSelectArray['select'] = [ { aggFn: 'count', @@ -436,12 +433,10 @@ export default function EditTimeChartForm({ setValue('select', heatmapSeries); setValue('series', heatmapSeries); setValue('series.0.countExpression' as any, 'count()'); - if (traceSource) { - setValue('numberFormat', { - output: 'duration' as any, - factor: 0.001, - }); - } + setValue('numberFormat', { + output: 'duration' as any, + factor: 0.001, + }); } // Don't auto-submit when config type changes, to avoid clearing form state (like source) From be51b978163654d215f5647d856c5c381b58928a Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Tue, 21 Apr 2026 18:24:55 -0700 Subject: [PATCH 24/33] fix: move Y-axis size calculation into options useMemo so it measures formatted tick labels The static opt.axes[1].size function was measuring raw values from a prior uPlot cycle, not the actual formatted labels produced by the values callback (tickFormatter). This caused the Y-axis to be too narrow for longer labels like '1.67min'. Moving size into the options useMemo alongside the values callback ensures it always measures the same formatted strings that are actually rendered. --- .../app/src/components/DBHeatmapChart.tsx | 74 +++++++------------ 1 file changed, 26 insertions(+), 48 deletions(-) diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index 02783d42e7..c9cf2b8c08 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -28,28 +28,15 @@ import { isAggregateFunction, timeBucketByGranularity } from '@/ChartUtils'; import { useQueriedChartConfig } from '@/hooks/useChartConfig'; import { NumberFormat } from '@/types'; import { FormatTime } from '@/useFormatTime'; -import { formatDurationMs, formatNumber } from '@/utils'; +import { + formatDurationMs, + formatDurationMsCompact, + formatNumber, +} from '@/utils'; import ChartContainer from './charts/ChartContainer'; import { SQLPreview } from './ChartSQLPreview'; -/** Compact duration labels for axis ticks — fewer decimals, shorter units. */ -function formatDurationMsCompact(ms: number): string { - if (ms < 0) return `-${formatDurationMsCompact(-ms)}`; - if (ms === 0) return '0'; - if (ms < 0.001) return `${+(ms * 1e6).toPrecision(2)}ns`; - if (ms < 1) { - const µs = ms * 1000; - return µs < 10 ? `${+µs.toPrecision(2)}µs` : `${Math.round(µs)}µs`; - } - if (ms < 1000) { - return ms < 10 ? `${+ms.toPrecision(2)}ms` : `${Math.round(ms)}ms`; - } - if (ms < 120_000) return `${+(ms / 1000).toPrecision(3)}s`; - if (ms < 3_600_000) return `${+(ms / 60_000).toPrecision(2)}m`; - return `${+(ms / 3_600_000).toPrecision(2)}h`; -} - type Mode2DataArray = [number[], number[], number[]]; // From: https://github.com/leeoniya/uPlot/blob/a4edb297a9b80baf781f4d05a40fb52fae737bff/demos/latency-heatmap.html#L436 @@ -266,22 +253,6 @@ const opt: uPlot.Options = { }, { ...axis, - // Dynamic size: measure the widest formatted tick label + padding. - // Falls back to 50 when no values are available yet. - size(self, values) { - if (!values || values.length === 0) return 50; - const font = self.axes[1]?.font ?? '12px IBM Plex Mono, monospace'; - const ctx = self.ctx; - ctx.save(); - ctx.font = font; - let maxW = 0; - for (const v of values) { - const w = ctx.measureText(v).width; - if (w > maxW) maxW = w; - } - ctx.restore(); - return Math.ceil(maxW) + 16; - }, }, ], series: [ @@ -339,16 +310,6 @@ export type HeatmapChartConfig = { with?: BuilderChartConfigWithDateRange['with']; }; -/** - * Extra fields stored on heatmap select items alongside the standard - * DerivedColumn schema. These aren't part of the Zod schema but are - * preserved through MongoDB and the form state. - */ -type HeatmapSelectExtras = { - countExpression?: string; - heatmapScaleType?: HeatmapScaleType; -}; - /** Build a HeatmapChartConfig from a builder chart config that has heatmap extras on select[0]. */ export function toHeatmapChartConfig(config: BuilderChartConfigWithDateRange): { heatmapConfig: HeatmapChartConfig; @@ -357,7 +318,6 @@ export function toHeatmapChartConfig(config: BuilderChartConfigWithDateRange): { const firstSelect = Array.isArray(config.select) ? config.select[0] : undefined; - const extras = (firstSelect ?? {}) as HeatmapSelectExtras; return { heatmapConfig: { ...config, @@ -366,13 +326,13 @@ export function toHeatmapChartConfig(config: BuilderChartConfigWithDateRange): { { aggFn: 'heatmap' as const, valueExpression: firstSelect?.valueExpression ?? '', - countExpression: extras.countExpression, + countExpression: firstSelect?.countExpression, }, ], granularity: 'auto', numberFormat: config.numberFormat, }, - scaleType: extras.heatmapScaleType === 'linear' ? 'linear' : 'log', + scaleType: firstSelect?.heatmapScaleType === 'linear' ? 'linear' : 'log', }; } @@ -948,9 +908,27 @@ function Heatmap({ opt.axes[0], { ...opt.axes[1], - values: (u, vals) => { + values: (_u: uPlot, vals: number[]) => { return vals.map(tickFormatter); }, + // Override the static size fn so it measures the actual + // formatted labels (from tickFormatter) rather than + // whatever raw values uPlot passes in a prior cycle. + size(self: uPlot, values: string[]) { + if (!values || values.length === 0) return 50; + const font = + self.axes[1]?.font ?? '12px IBM Plex Mono, monospace'; + const ctx = self.ctx; + ctx.save(); + ctx.font = font; + let maxW = 0; + for (const v of values) { + const w = ctx.measureText(v).width; + if (w > maxW) maxW = w; + } + ctx.restore(); + return Math.ceil(maxW) + 16; + }, // For log scale, place ticks at powers of 10 (0.01, 0.1, 1, // 10, 100…) so labels are clean round numbers instead of // arbitrary positions in log-space. From 435663be31b4ef68b1e313436c657857346e6723 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Wed, 22 Apr 2026 17:53:13 -0700 Subject: [PATCH 25/33] feat: add click-popover drill-down from dashboard heatmap tiles to Event Deltas Drag-select is intercepted by react-grid-layout when the heatmap is inside a dashboard tile, making the existing drag-based drill-down unreachable. Replace it with a click-popover pattern matching the line chart (DBTimeChart): clicking anywhere on the heatmap opens a small popover with a 'View in Event Deltas' link that preserves source, where clause, filters, and time range via buildEventsSearchUrl + mode=delta. --- .claude/settings.json | 28 ++++ packages/app/src/DBDashboardPage.tsx | 129 ++++++++++++++++-- packages/app/src/__tests__/utils.test.ts | 47 +++++++ .../ChartEditor/__tests__/utils.test.ts | 103 ++++++++++++++ .../ChartEditorControls.tsx | 4 +- .../DBEditTimeChartForm/EditTimeChartForm.tsx | 30 ++-- .../src/components/HeatmapSettingsDrawer.tsx | 17 ++- .../Search/DBSearchHeatmapChart.tsx | 12 +- packages/app/src/utils.ts | 17 +++ packages/common-utils/src/types.ts | 3 + 10 files changed, 346 insertions(+), 44 deletions(-) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000..f48ce65f7e --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,28 @@ +{ + "permissions": { + "allow": [ + "Read(//Users/alex/projects/worktrees/**)", + "Read(//Users/alex/projects/worktrees/$BASE_NAME//**)", + "Read(//Users/alex/projects/worktrees/$BASE_NAME/origin-cursor-heatmap-chart-editor-dashboard-5801/**)", + "Bash(grep -E \"\\\\.\\(tsx?|jsx?\\)$\")", + "Read(//Users/alex/projects/worktrees/$BASE_NAME/**)", + "Bash(yarn tsc *)", + "Bash(yarn install *)", + "Bash(yarn app:typecheck)", + "Bash(make ci-lint *)", + "Bash(make ci-unit *)", + "Read(//Users/alex/projects/**)", + "Read(//Users/alex/projects/worktrees/$BASE_NAME/origin-cursor-heatmap-chart-editor-dashboard-5801/packages/app/src/**)", + "Read(//Users/alex/projects/worktrees/$BASE_NAME/origin-cursor-heatmap-chart-editor-dashboard-5801/packages/app/src/components/**)", + "Read(//Users/alex/projects/worktrees/$BASE_NAME/origin-cursor-heatmap-chart-editor-dashboard-5801/node_modules/@hyperdx/**)", + "Read(//Users/alex/projects/worktrees/$BASE_NAME/origin-cursor-heatmap-chart-editor-dashboard-5801/packages/app/src/__tests__/**)", + "Read(//Users/alex/projects/worktrees/$BASE_NAME/origin-cursor-heatmap-chart-editor-dashboard-5801/packages/app/src/components/ChartEditor/__tests__/**)", + "Read(//Users/alex/projects/worktrees/$BASE_NAME/origin-cursor-heatmap-chart-editor-dashboard-5801/packages/app/**)", + "Bash(npx eslint *)", + "Bash(yarn ci:unit --testPathPattern='__tests__/utils.test')", + "Bash(npx jest *)", + "Bash(yarn lint:fix)", + "Bash(grep -B2 -A6 \"ReactGridLayout$\" packages/app/src/DBDashboardPage.tsx)" + ] + } +} diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index e29629436d..07b4a88c30 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -45,6 +45,7 @@ import { SearchConditionLanguage, SourceKind, SQLInterval, + TSource, } from '@hyperdx/common-utils/dist/types'; import { ActionIcon, @@ -58,6 +59,8 @@ import { Menu, Modal, Paper, + Popover, + Portal, Stack, Text, Tooltip, @@ -78,6 +81,7 @@ import { IconPlayerPlay, IconPlus, IconRefresh, + IconSearch, IconTags, IconTrash, IconUpload, @@ -116,7 +120,11 @@ import useDashboardFilters from './hooks/useDashboardFilters'; import { useDashboardRefresh } from './hooks/useDashboardRefresh'; import { useBrandDisplayName } from './theme/ThemeProvider'; import { parseAsJsonEncoded, parseAsStringEncoded } from './utils/queryParsers'; -import { buildTableRowSearchUrl, DEFAULT_CHART_CONFIG } from './ChartUtils'; +import { + buildEventsSearchUrl, + buildTableRowSearchUrl, + DEFAULT_CHART_CONFIG, +} from './ChartUtils'; import { useConnections } from './connection'; import { useDashboard } from './dashboard'; import DashboardFilters from './DashboardFilters'; @@ -145,22 +153,125 @@ function HeatmapTile({ title, toolbar, queriedConfig, + source, + dateRange, }: { keyPrefix: string; chartId: string; title: React.ReactNode; toolbar: React.ReactNode[]; queriedConfig: BuilderChartConfigWithDateRange; + source: TSource | undefined; + dateRange: [Date, Date]; }) { const { heatmapConfig, scaleType } = toHeatmapChartConfig(queriedConfig); + + const [clickPos, setClickPos] = useState<{ x: number; y: number } | null>( + null, + ); + const containerRef = useRef(null); + + const eventDeltasUrl = useMemo(() => { + if (!source) return null; + const url = buildEventsSearchUrl({ + source, + config: queriedConfig, + dateRange, + }); + if (!url) return null; + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}mode=delta`; + }, [source, queriedConfig, dateRange]); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (!eventDeltasUrl) return; + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + setClickPos({ x: e.clientX - rect.left, y: e.clientY - rect.top }); + }, + [eventDeltasUrl], + ); + + const dismiss = useCallback(() => setClickPos(null), []); + return ( - +
+ + {clickPos != null && eventDeltasUrl != null && ( + <> + +
{ + e.stopPropagation(); + e.preventDefault(); + dismiss(); + }} + onMouseDown={e => e.stopPropagation()} + /> + + { + if (!opened) dismiss(); + }} + position="bottom-start" + offset={4} + withinPortal + closeOnEscape + withArrow + shadow="md" + > + +
+ + e.stopPropagation()} + onMouseDown={e => e.stopPropagation()} + > + + + + View in Event Deltas + + + + + + )} +
); } @@ -636,6 +747,8 @@ const Tile = forwardRef( title={title} toolbar={toolbar} queriedConfig={queriedConfig} + source={source} + dateRange={dateRange} /> )} {effectiveMarkdownConfig?.displayType === diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts index 5f9e334492..ae6aeadf71 100644 --- a/packages/app/src/__tests__/utils.test.ts +++ b/packages/app/src/__tests__/utils.test.ts @@ -7,6 +7,7 @@ import * as utils from '../utils'; import { formatAttributeClause, formatDurationMs, + formatDurationMsCompact, formatNumber, getAllMetricTables, getMetricTableName, @@ -1123,3 +1124,49 @@ describe('mapKeyBy', () => { expect(result.get('a')).toBe(data.at(1)); }); }); + +describe('formatDurationMsCompact', () => { + it('returns 0 for zero', () => { + expect(formatDurationMsCompact(0)).toBe('0'); + }); + + it('formats negative values', () => { + expect(formatDurationMsCompact(-5)).toBe('-5ms'); + }); + + it('formats nanoseconds (< 0.001 ms)', () => { + expect(formatDurationMsCompact(0.0005)).toBe('500ns'); + expect(formatDurationMsCompact(0.00012)).toBe('120ns'); + }); + + it('formats microseconds (< 1 ms)', () => { + expect(formatDurationMsCompact(0.005)).toBe('5µs'); + expect(formatDurationMsCompact(0.5)).toBe('500µs'); + expect(formatDurationMsCompact(0.123)).toBe('123µs'); + }); + + it('formats milliseconds (< 1000 ms)', () => { + expect(formatDurationMsCompact(5)).toBe('5ms'); + expect(formatDurationMsCompact(5.67)).toBe('5.7ms'); + expect(formatDurationMsCompact(100)).toBe('100ms'); + expect(formatDurationMsCompact(999)).toBe('999ms'); + }); + + it('formats seconds (< 2 min)', () => { + expect(formatDurationMsCompact(1000)).toBe('1s'); + expect(formatDurationMsCompact(5432)).toBe('5.43s'); + expect(formatDurationMsCompact(60_000)).toBe('60s'); + expect(formatDurationMsCompact(119_999)).toBe('120s'); + }); + + it('formats minutes (< 1 hour)', () => { + expect(formatDurationMsCompact(120_000)).toBe('2m'); + expect(formatDurationMsCompact(300_000)).toBe('5m'); + expect(formatDurationMsCompact(3_599_999)).toBe('60m'); + }); + + it('formats hours (>= 1 hour)', () => { + expect(formatDurationMsCompact(3_600_000)).toBe('1h'); + expect(formatDurationMsCompact(7_200_000)).toBe('2h'); + }); +}); diff --git a/packages/app/src/components/ChartEditor/__tests__/utils.test.ts b/packages/app/src/components/ChartEditor/__tests__/utils.test.ts index 79b18f755c..2e1f76a8de 100644 --- a/packages/app/src/components/ChartEditor/__tests__/utils.test.ts +++ b/packages/app/src/components/ChartEditor/__tests__/utils.test.ts @@ -51,6 +51,25 @@ const metricSource: TMetricSource = { resourceAttributesExpression: 'ResourceAttributes', }; +const traceSource: TSource = { + id: 'source-trace', + name: 'Trace Source', + kind: SourceKind.Trace, + connection: 'conn-1', + from: { databaseName: 'db', tableName: 'spans' }, + timestampValueExpression: 'Timestamp', + durationExpression: 'Duration', + spanIdExpression: 'SpanId', + traceIdExpression: 'TraceId', + parentSpanIdExpression: 'ParentSpanId', + defaultTableSelectExpression: 'SpanName', + implicitColumnExpression: 'SpanName', + statusCodeExpression: 'StatusCode', + spanNameExpression: 'SpanName', + spanKindExpression: 'SpanKind', + durationPrecision: 9, +}; + const seriesItem = { aggFn: 'count' as const, valueExpression: '*', @@ -1251,4 +1270,88 @@ describe('validateChartForm', () => { expect.objectContaining({ type: 'manual' }), ); }); + + // ── Heatmap-specific validation ─────────────────────────────────────── + + it('returns no errors for a valid heatmap chart with trace source', () => { + const setError = jest.fn(); + const errors = validateChartForm( + makeForm({ + displayType: DisplayType.Heatmap, + source: 'source-trace', + series: [ + { + ...seriesItem, + valueExpression: 'Duration / 1e6', + countExpression: 'count()', + heatmapScaleType: 'log', + }, + ], + }), + traceSource, + setError, + ); + expect(errors).toHaveLength(0); + }); + + it('rejects heatmap chart with multiple series', () => { + const setError = jest.fn(); + const errors = validateChartForm( + makeForm({ + displayType: DisplayType.Heatmap, + source: 'source-trace', + series: [ + { ...seriesItem, valueExpression: 'Duration / 1e6' }, + { ...seriesItem, valueExpression: 'other' }, + ], + }), + traceSource, + setError, + ); + expect(errors).toContainEqual(expect.objectContaining({ path: 'series' })); + }); + + it('rejects heatmap chart without a value expression', () => { + const setError = jest.fn(); + const errors = validateChartForm( + makeForm({ + displayType: DisplayType.Heatmap, + source: 'source-trace', + series: [{ ...seriesItem, valueExpression: '' }], + }), + traceSource, + setError, + ); + expect(errors).toContainEqual( + expect.objectContaining({ path: 'series.0.valueExpression' }), + ); + }); +}); + +describe('heatmap round-trip', () => { + it('preserves countExpression and heatmapScaleType through form state conversion', () => { + const form: ChartEditorFormState = { + displayType: DisplayType.Heatmap, + source: 'source-trace', + where: '', + series: [ + { + ...seriesItem, + valueExpression: 'Duration / 1e6', + countExpression: 'count()', + heatmapScaleType: 'linear', + }, + ], + }; + const saved = convertFormStateToSavedChartConfig(form, traceSource); + expect(saved).toBeDefined(); + const restored = convertSavedChartConfigToFormState(saved!); + expect(restored.series[0]).toEqual( + expect.objectContaining({ + valueExpression: 'Duration / 1e6', + countExpression: 'count()', + heatmapScaleType: 'linear', + }), + ); + }); }); diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx index 9cc739838a..52e3feb781 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx @@ -57,7 +57,7 @@ type ChartEditorControlsProps = { chartConfigForExplanations?: ChartConfigWithOptTimestamp; onSubmit: (suppressErrorNotification?: boolean) => void; openDisplaySettings: () => void; - openHeatmapSettings?: () => void; + openHeatmapSettings: () => void; }; export function ChartEditorControls({ @@ -127,7 +127,7 @@ export function ChartEditorControls({ setValue={setValue} tableSource={tableSource} onSubmit={onSubmit} - onOpenDisplaySettings={openHeatmapSettings ?? openDisplaySettings} + onOpenDisplaySettings={openHeatmapSettings} /> ) : displayType !== DisplayType.Search && Array.isArray(select) ? ( <> diff --git a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx index 6b35a513ab..9bcf7caf9a 100644 --- a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx @@ -432,9 +432,9 @@ export default function EditTimeChartForm({ ]; setValue('select', heatmapSeries); setValue('series', heatmapSeries); - setValue('series.0.countExpression' as any, 'count()'); + setValue('series.0.countExpression', 'count()'); setValue('numberFormat', { - output: 'duration' as any, + output: 'duration', factor: 0.001, }); } @@ -469,8 +469,8 @@ export default function EditTimeChartForm({ ]; setValue('select', heatmapSeries); setValue('series', heatmapSeries); - setValue('series.0.countExpression' as any, 'count()'); - setValue('numberFormat', { output: 'duration' as any, factor: 0.001 }); + setValue('series.0.countExpression', 'count()'); + setValue('numberFormat', { output: 'duration', factor: 0.001 }); onSubmit(true); } }, [sourceId, displayType, tableSource, setValue, onSubmit]); @@ -536,40 +536,36 @@ export default function EditTimeChartForm({ const handleUpdateHeatmapSettings = useCallback( (data: HeatmapSettingsValues) => { setValue('series.0.valueExpression', data.value); - setValue('series.0.countExpression' as any, data.count || 'count()'); + setValue('series.0.countExpression', data.count || 'count()'); + setValue('series.0.heatmapScaleType', data.scaleType); onSubmit(); closeHeatmapSettings(); }, [setValue, onSubmit, closeHeatmapSettings], ); - const handleHeatmapScaleTypeChange = useCallback( - (v: HeatmapScaleType) => { - setValue('series.0.heatmapScaleType' as any, v); - }, - [setValue], - ); - const heatmapValueExpression = useWatch({ control, name: 'series.0.valueExpression', }); const heatmapCountExpression = useWatch({ control, - name: 'series.0.countExpression' as any, + name: 'series.0.countExpression', }); const heatmapScaleTypeRaw = useWatch({ control, - name: 'series.0.heatmapScaleType' as any, + name: 'series.0.heatmapScaleType', }); - const heatmapScaleType: HeatmapScaleType = heatmapScaleTypeRaw ?? 'log'; + const heatmapScaleType: HeatmapScaleType = + heatmapScaleTypeRaw === 'linear' ? 'linear' : 'log'; const heatmapSettingsDefaults = useMemo( () => ({ value: heatmapValueExpression || '', count: heatmapCountExpression || 'count()', + scaleType: heatmapScaleType, }), - [heatmapValueExpression, heatmapCountExpression], + [heatmapValueExpression, heatmapCountExpression, heatmapScaleType], ); const tableConnection = useMemo( @@ -782,8 +778,6 @@ export default function EditTimeChartForm({ connection={tableConnection} parentRef={parentRef} defaultValues={heatmapSettingsDefaults} - scaleType={heatmapScaleType} - onScaleTypeChange={handleHeatmapScaleTypeChange} onSubmit={handleUpdateHeatmapSettings} />
diff --git a/packages/app/src/components/HeatmapSettingsDrawer.tsx b/packages/app/src/components/HeatmapSettingsDrawer.tsx index d3b6a429c1..a3cff94a03 100644 --- a/packages/app/src/components/HeatmapSettingsDrawer.tsx +++ b/packages/app/src/components/HeatmapSettingsDrawer.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect } from 'react'; -import { useForm } from 'react-hook-form'; +import { useForm, useWatch } from 'react-hook-form'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; @@ -15,12 +15,12 @@ import { } from '@mantine/core'; import { IconPlayerPlay } from '@tabler/icons-react'; -import type { HeatmapScaleType } from '@/components/DBHeatmapChart'; import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor'; export const HeatmapSettingsSchema = z.object({ value: z.string().trim().min(1), count: z.string().trim().optional(), + scaleType: z.enum(['log', 'linear']).default('log'), }); export type HeatmapSettingsValues = z.infer; @@ -31,8 +31,6 @@ export default function HeatmapSettingsDrawer({ connection, parentRef, defaultValues, - scaleType, - onScaleTypeChange, onSubmit, }: { opened: boolean; @@ -40,8 +38,6 @@ export default function HeatmapSettingsDrawer({ connection: TableConnection; parentRef?: HTMLElement | null; defaultValues: HeatmapSettingsValues; - scaleType: HeatmapScaleType; - onScaleTypeChange: (v: HeatmapScaleType) => void; onSubmit: (v: HeatmapSettingsValues) => void; }) { const form = useForm({ @@ -59,6 +55,8 @@ export default function HeatmapSettingsDrawer({ onClose(); }, [onClose, form, defaultValues]); + const scaleType = useWatch({ control: form.control, name: 'scaleType' }); + return ( onScaleTypeChange(v as HeatmapScaleType)} + onChange={v => + form.setValue( + 'scaleType', + v as HeatmapSettingsValues['scaleType'], + ) + } data={[ { label: 'Log', value: 'log' }, { label: 'Linear', value: 'linear' }, diff --git a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx index ee099ef987..3a5e2436f1 100644 --- a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx +++ b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx @@ -52,12 +52,6 @@ export function DBSearchHeatmapChart({ }); const [container, setContainer] = useState(null); const scaleType = (fields.scaleType ?? 'log') as HeatmapScaleType; - const setScaleType = useCallback( - (v: HeatmapScaleType) => { - void setFields({ scaleType: v }); - }, - [setFields], - ); const [settingsOpened, settingsHandlers] = useDisclosure(false); const { colorScheme } = useMantineColorScheme(); const palette = colorScheme === 'light' ? lightPalette : darkPalette; @@ -66,8 +60,9 @@ export function DBSearchHeatmapChart({ () => ({ value: fields.value, count: fields.count ?? 'count()', + scaleType, }), - [fields.value, fields.count], + [fields.value, fields.count, scaleType], ); // After applying a filter, clear the heatmap selection so the delta chart @@ -148,12 +143,11 @@ export function DBSearchHeatmapChart({ connection={tcFromSource(source)} parentRef={container} defaultValues={heatmapSettingsDefaults} - scaleType={scaleType} - onScaleTypeChange={setScaleType} onSubmit={data => { setFields({ value: data.value, count: data.count, + scaleType: data.scaleType, }); settingsHandlers.close(); }} diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index a011234437..50239645fa 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -912,6 +912,23 @@ export function formatDurationMs(ms: number): string { return `${parseFloat((ms / 3_600_000).toFixed(2))}h`; } +/** Compact duration labels for axis ticks — fewer decimals, shorter units. */ +export function formatDurationMsCompact(ms: number): string { + if (ms < 0) return `-${formatDurationMsCompact(-ms)}`; + if (ms === 0) return '0'; + if (ms < 0.001) return `${+(ms * 1e6).toPrecision(2)}ns`; + if (ms < 1) { + const µs = ms * 1000; + return µs < 10 ? `${+µs.toPrecision(2)}µs` : `${Math.round(µs)}µs`; + } + if (ms < 1000) { + return ms < 10 ? `${+ms.toPrecision(2)}ms` : `${Math.round(ms)}ms`; + } + if (ms < 120_000) return `${+(ms / 1000).toPrecision(3)}s`; + if (ms < 3_600_000) return `${+(ms / 60_000).toPrecision(2)}m`; + return `${+(ms / 3_600_000).toPrecision(2)}h`; +} + // format uptime as days, hours, minutes or seconds export const formatUptime = (seconds: number) => { if (seconds < 60) { diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 5d7d483436..d18f0f77f6 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -137,6 +137,9 @@ export const DerivedColumnSchema = z.intersection( metricType: z.nativeEnum(MetricsDataType).optional(), metricName: z.string().optional(), metricNameSql: z.string().optional(), + // Heatmap-specific fields (optional, only used when displayType is Heatmap) + countExpression: z.string().optional(), + heatmapScaleType: z.string().optional(), }), ); export const SelectListSchema = z.array(DerivedColumnSchema).or(z.string()); From a2859691af14874d5a502a301d8e6111cba5538f Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Wed, 22 Apr 2026 17:53:31 -0700 Subject: [PATCH 26/33] chore: remove accidentally-committed local Claude Code settings .claude/settings.json contains user-local tool permission preferences and should not be in the repo. --- .claude/settings.json | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index f48ce65f7e..0000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "permissions": { - "allow": [ - "Read(//Users/alex/projects/worktrees/**)", - "Read(//Users/alex/projects/worktrees/$BASE_NAME//**)", - "Read(//Users/alex/projects/worktrees/$BASE_NAME/origin-cursor-heatmap-chart-editor-dashboard-5801/**)", - "Bash(grep -E \"\\\\.\\(tsx?|jsx?\\)$\")", - "Read(//Users/alex/projects/worktrees/$BASE_NAME/**)", - "Bash(yarn tsc *)", - "Bash(yarn install *)", - "Bash(yarn app:typecheck)", - "Bash(make ci-lint *)", - "Bash(make ci-unit *)", - "Read(//Users/alex/projects/**)", - "Read(//Users/alex/projects/worktrees/$BASE_NAME/origin-cursor-heatmap-chart-editor-dashboard-5801/packages/app/src/**)", - "Read(//Users/alex/projects/worktrees/$BASE_NAME/origin-cursor-heatmap-chart-editor-dashboard-5801/packages/app/src/components/**)", - "Read(//Users/alex/projects/worktrees/$BASE_NAME/origin-cursor-heatmap-chart-editor-dashboard-5801/node_modules/@hyperdx/**)", - "Read(//Users/alex/projects/worktrees/$BASE_NAME/origin-cursor-heatmap-chart-editor-dashboard-5801/packages/app/src/__tests__/**)", - "Read(//Users/alex/projects/worktrees/$BASE_NAME/origin-cursor-heatmap-chart-editor-dashboard-5801/packages/app/src/components/ChartEditor/__tests__/**)", - "Read(//Users/alex/projects/worktrees/$BASE_NAME/origin-cursor-heatmap-chart-editor-dashboard-5801/packages/app/**)", - "Bash(npx eslint *)", - "Bash(yarn ci:unit --testPathPattern='__tests__/utils.test')", - "Bash(npx jest *)", - "Bash(yarn lint:fix)", - "Bash(grep -B2 -A6 \"ReactGridLayout$\" packages/app/src/DBDashboardPage.tsx)" - ] - } -} From e89d581aa5b630a116686f36d68c264522f0375e Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Wed, 22 Apr 2026 18:03:27 -0700 Subject: [PATCH 27/33] refactor: extract heatmap default-series helper, add changeset - Deduplicate heatmap defaults setup (previously inlined in two useEffects) into applyHeatmapDefaults() - Add changeset describing the feature for the next release --- .changeset/heatmap-chart-editor-dashboard.md | 13 +++++ .../DBEditTimeChartForm/EditTimeChartForm.tsx | 57 +++++++++---------- 2 files changed, 41 insertions(+), 29 deletions(-) create mode 100644 .changeset/heatmap-chart-editor-dashboard.md diff --git a/.changeset/heatmap-chart-editor-dashboard.md b/.changeset/heatmap-chart-editor-dashboard.md new file mode 100644 index 0000000000..c17ea2fc0e --- /dev/null +++ b/.changeset/heatmap-chart-editor-dashboard.md @@ -0,0 +1,13 @@ +--- +'@hyperdx/app': minor +'@hyperdx/common-utils': patch +--- + +feat: heatmap charts in chart editor and dashboards + +- Heatmap is now a selectable display type in the chart editor tabs +- Dashboard tiles render heatmaps via the shared `DBHeatmapChart` component +- Heatmap source picker restricted to trace sources; value/count expressions auto-populate from the source's duration expression +- Display Settings drawer (scale, value, count) shared across search Event Deltas, chart editor, and dashboards +- Click a dashboard heatmap tile to open Event Deltas with source, where clause, filters, and time range preserved +- Dynamic Y-axis sizing measures formatted tick labels so long labels (e.g. "1.67min") are not clipped diff --git a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx index 9bcf7caf9a..f3ee14b7a1 100644 --- a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx @@ -1,5 +1,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Controller, useFieldArray, useForm, useWatch } from 'react-hook-form'; +import { + Controller, + useFieldArray, + useForm, + type UseFormSetValue, + useWatch, +} from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { @@ -98,6 +104,25 @@ type EditTimeChartFormProps = { autoRun?: boolean; }; +/** Populate form state with the standard heatmap series + duration numberFormat. */ +function applyHeatmapDefaults( + setValue: UseFormSetValue, + valueExpression: string, +) { + const heatmapSeries: SavedChartConfigWithSelectArray['select'] = [ + { + aggFn: 'count', + aggCondition: '', + aggConditionLanguage: getStoredLanguage() ?? 'lucene', + valueExpression, + }, + ]; + setValue('select', heatmapSeries); + setValue('series', heatmapSeries); + setValue('series.0.countExpression', 'count()'); + setValue('numberFormat', { output: 'duration', factor: 0.001 }); +} + export default function EditTimeChartForm({ dashboardId, chartConfig, @@ -422,21 +447,7 @@ export default function EditTimeChartForm({ tableSource.durationExpression ? getDurationMsExpression(tableSource) : (select[0]?.valueExpression ?? ''); - const heatmapSeries: SavedChartConfigWithSelectArray['select'] = [ - { - aggFn: 'count', - aggCondition: '', - aggConditionLanguage: getStoredLanguage() ?? 'lucene', - valueExpression: defaultValue, - }, - ]; - setValue('select', heatmapSeries); - setValue('series', heatmapSeries); - setValue('series.0.countExpression', 'count()'); - setValue('numberFormat', { - output: 'duration', - factor: 0.001, - }); + applyHeatmapDefaults(setValue, defaultValue); } // Don't auto-submit when config type changes, to avoid clearing form state (like source) @@ -458,19 +469,7 @@ export default function EditTimeChartForm({ tableSource?.kind === SourceKind.Trace && tableSource.durationExpression ) { - const durationExpr = getDurationMsExpression(tableSource); - const heatmapSeries: SavedChartConfigWithSelectArray['select'] = [ - { - aggFn: 'count', - aggCondition: '', - aggConditionLanguage: getStoredLanguage() ?? 'lucene', - valueExpression: durationExpr, - }, - ]; - setValue('select', heatmapSeries); - setValue('series', heatmapSeries); - setValue('series.0.countExpression', 'count()'); - setValue('numberFormat', { output: 'duration', factor: 0.001 }); + applyHeatmapDefaults(setValue, getDurationMsExpression(tableSource)); onSubmit(true); } }, [sourceId, displayType, tableSource, setValue, onSubmit]); From c8970d685ea316e983c6078d40a3759b1c2eaadd Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Wed, 22 Apr 2026 18:25:40 -0700 Subject: [PATCH 28/33] fix: persist heatmap drag-select rectangle across 'Filter by Selection' clicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The options useMemo depended on the onFilter function reference. Since DBSearchHeatmapChart passes an inline arrow to onFilter, the reference changed on every parent render — so after clicking 'Filter by Selection' (which calls setFields() and triggers a re-render), the options object recomputed, uPlot re-initialized, and its internal u.select state (the rectangle) was wiped. Depend on a boolean (`hasFilter = !!onFilter`) instead of the function itself. The drag-enabled state is what the options actually care about, and the boolean only flips when onFilter goes from undefined to defined (which is a context switch, not a per-render change). --- packages/app/src/components/DBHeatmapChart.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index c9cf2b8c08..5328491120 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -867,6 +867,11 @@ function Heatmap({ // on init (before user hovers), which would show the tooltip on page load. const mouseInsideRef = useRef(false); + // Depend on the boolean, not the onFilter function reference, so the + // options useMemo doesn't recompute (and re-initialize uPlot — wiping its + // internal u.select drag rectangle) on every parent render. + const hasFilter = !!onFilter; + const { ref, width, height } = useElementSize(); const tickFormatter = useCallback( @@ -980,8 +985,8 @@ function Heatmap({ cursor: { drag: { setScale: false, - x: !!onFilter, - y: !!onFilter, + x: hasFilter, + y: hasFilter, dist: 5, }, show: true, @@ -1061,7 +1066,7 @@ function Heatmap({ }, ], }; - }, [width, height, tickFormatter, scaleType, palette, onFilter]); + }, [width, height, tickFormatter, scaleType, palette, hasFilter]); return (
Date: Wed, 22 Apr 2026 18:48:36 -0700 Subject: [PATCH 29/33] =?UTF-8?q?refactor:=20heatmap=20drag-to-compare=20U?= =?UTF-8?q?X=20=E2=80=94=20drop=20'Filter=20by=20Selection'=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: drag → rectangle + 'Filter by Selection' button → click button → filter applies. After: drag → filter applies immediately; click anywhere on chart clears selection + exits comparison mode. Rationale: the button was an extra step for the only action that made sense after a drag. Applying on drag-end matches how selection works in most analytics tools and removes the 'how do I commit this?' discoverability gap. - Remove selectingInfo state and the 'Filter by Selection' floating button - setSelect hook now fires onFilter directly (with log→linear y conversion and time-unit scaling that the button did previously) - Add onClearFilter prop; container's onClick calls u.setSelect(zero) and onClearFilter, guarded by a justDraggedAtRef timestamp to ignore the synthetic click that follows mouseup after a drag - Restore uPlot instance capture via onCreate/onDelete for programmatic u.select clearing on outside-click - DBSearchHeatmapChart passes onClearFilter that resets xMin/xMax/yMin/yMax URL params (exits comparison mode) - Tooltip hint: 'Click & Drag to Select Data' → 'Drag to Compare · Click to Clear' --- .../app/src/components/DBHeatmapChart.tsx | 156 ++++++------------ .../Search/DBSearchHeatmapChart.tsx | 3 + 2 files changed, 58 insertions(+), 101 deletions(-) diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index 5328491120..c8f4d5b7b7 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import dynamic from 'next/dynamic'; import type { Plugin } from 'uplot'; import uPlot from 'uplot'; @@ -373,6 +373,7 @@ function HeatmapContainer({ config, enabled = true, onFilter, + onClearFilter, title, toolbarPrefix, toolbarSuffix, @@ -381,6 +382,7 @@ function HeatmapContainer({ config: HeatmapChartConfig; enabled?: boolean; onFilter?: (xMin: number, xMax: number, yMin: number, yMax: number) => void; + onClearFilter?: () => void; title?: React.ReactNode; toolbarPrefix?: React.ReactNode[]; toolbarSuffix?: React.ReactNode[]; @@ -710,6 +712,7 @@ function HeatmapContainer({ } : undefined } + onClearFilter={onClearFilter} scaleType={scaleType} palette={palette} /> @@ -823,31 +826,17 @@ function Heatmap({ data, numberFormat, onFilter, + onClearFilter, scaleType = 'linear', palette, }: { data: Mode2DataArray; numberFormat?: NumberFormat; onFilter?: (xMin: number, xMax: number, yMin: number, yMax: number) => void; + onClearFilter?: () => void; scaleType?: HeatmapScaleType; palette: string[]; }) { - const [selectingInfo, setSelectingInfo] = useState< - | { - // In pixel units - top: number; - left: number; - width: number; - height: number; - // In data units - xMin: number; - yMin: number; - xMax: number; - yMax: number; - } - | undefined - >(undefined); - const [highlightedPoint, setHighlightedPoint] = useState< | { xVal: number; @@ -872,6 +861,23 @@ function Heatmap({ // internal u.select drag rectangle) on every parent render. const hasFilter = !!onFilter; + // Hold onFilter in a ref so the setSelect hook (captured inside the + // options useMemo) can always call the latest callback without needing + // onFilter in the memo's dep array. + const onFilterRef = useRef(onFilter); + useEffect(() => { + onFilterRef.current = onFilter; + }, [onFilter]); + + // Hold the uPlot instance so outside-click can explicitly clear the + // persisted u.select rectangle (which is owned by uPlot, not React). + const uplotRef = useRef(null); + + // Timestamp of the most recent drag-end. Guards the container's onClick + // handler from clearing the selection when the synthetic click event + // that fires on mouseup-after-drag arrives. + const justDraggedAtRef = useRef(0); + const { ref, width, height } = useElementSize(); const tickFormatter = useCallback( @@ -1038,29 +1044,21 @@ function Heatmap({ return; } - // Calculate offset from parent so we can render tooltip - // relative to the parent pixels - const { offsetLeft, offsetTop } = u.over; - const xMin = u.posToVal(u.select.left, 'x'); const xMax = u.posToVal(u.select.left + u.select.width, 'x'); - const yMax = u.posToVal(u.select.top, 'y'); - const yMin = u.posToVal(u.select.top + u.select.height, 'y'); - - // This ensures we set the timeout after all click handlers - // to prevent our state from being wiped by onclick handler - setTimeout(() => { - setSelectingInfo({ - top: u.select.top + offsetTop, - left: u.select.left + offsetLeft, - width: u.select.width, - height: u.select.height, - xMin, - xMax, - yMin, - yMax, - }); - }, 20); + const rawYMax = u.posToVal(u.select.top, 'y'); + const rawYMin = u.posToVal(u.select.top + u.select.height, 'y'); + + // y-values are stored in log space for log scale; convert back + const yMin = scaleType === 'log' ? Math.exp(rawYMin) : rawYMin; + const yMax = scaleType === 'log' ? Math.exp(rawYMax) : rawYMax; + + // Apply the filter immediately on drag end. Record the + // timestamp so the synthetic click event that follows the + // drag (mouseup fires a click on the container) doesn't + // immediately clear the selection we just made. + justDraggedAtRef.current = performance.now(); + onFilterRef.current?.(xMin / 1000, xMax / 1000, yMin, yMax); }, }, }, @@ -1074,9 +1072,19 @@ function Heatmap({ className="heatmap-selection-container" style={{ width: '100%', height: '100%', position: 'relative' }} onClick={() => { - if (selectingInfo != null) { - setSelectingInfo(undefined); + // Chromium fires a click event on mouseup even after a drag. + // Ignore it; the drag itself was handled by setSelect. + if (performance.now() - justDraggedAtRef.current < 300) { + return; } + if (!hasFilter) return; + // Random click on the chart clears the persisted selection and + // exits comparison mode. + uplotRef.current?.setSelect( + { left: 0, top: 0, width: 0, height: 0 }, + false, + ); + onClearFilter?.(); }} onMouseEnter={() => { mouseInsideRef.current = true; @@ -1091,6 +1099,12 @@ function Heatmap({ // @ts-expect-error TODO: uPlot types are wrong for mode 2 data data={[[], data]} resetScales={true} + onCreate={chart => { + uplotRef.current = chart; + }} + onDelete={() => { + uplotRef.current = null; + }} /> {highlightedPoint != null && ( <> @@ -1133,7 +1147,7 @@ function Heatmap({ {onFilter && ( <> - Click & Drag to Select Data + Drag to Compare · Click to Clear @@ -1154,66 +1168,6 @@ function Heatmap({
)} - {selectingInfo != null && onFilter != null && ( -
30 - ? { bottom: height - selectingInfo?.top + 4 } - : { - top: selectingInfo?.top + (selectingInfo?.height ?? 0) + 4, - }), - left: selectingInfo?.left, - }} - onClick={e => { - e.stopPropagation(); - // y-values are stored in log space for log scale; convert back - const yMin = - scaleType === 'log' - ? Math.exp(selectingInfo.yMin) - : selectingInfo.yMin; - const yMax = - scaleType === 'log' - ? Math.exp(selectingInfo.yMax) - : selectingInfo.yMax; - onFilter?.( - selectingInfo.xMin / 1000, - selectingInfo.xMax / 1000, - yMin, - yMax, - ); - }} - role="button" - tabIndex={0} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - const yMin = - scaleType === 'log' - ? Math.exp(selectingInfo.yMin) - : selectingInfo.yMin; - const yMax = - scaleType === 'log' - ? Math.exp(selectingInfo.yMax) - : selectingInfo.yMax; - onFilter?.( - selectingInfo.xMin / 1000, - selectingInfo.xMax / 1000, - yMin, - yMax, - ); - } - }} - > - Filter by Selection -
- )}
); } diff --git a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx index 3a5e2436f1..ecc5586a5e 100644 --- a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx +++ b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx @@ -119,6 +119,9 @@ export function DBSearchHeatmapChart({ onFilter={(xMin, xMax, yMin, yMax) => { setFields({ xMin, xMax, yMin, yMax }); }} + onClearFilter={() => { + setFields({ xMin: null, xMax: null, yMin: null, yMax: null }); + }} /> {/* Gear icon overlaid on chart top-right */} From 75090506936b51196a8dfef8878e57db9b2489c8 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Wed, 22 Apr 2026 18:56:14 -0700 Subject: [PATCH 30/33] fix: clear heatmap selection when time range or display settings change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related cases where the selection rectangle went away but the URL xMin/xMax/yMin/yMax params lingered, leaving the delta chart stuck in comparison mode against stale coordinates: 1. Time range change (time picker) — data refetches, uPlot re-initializes (rectangle gone), but the xMin/xMax/yMin/yMax URL params remained and the delta chart kept running its comparison against the new time range. Added a useEffect that tracks dateRange and clears selection on change. 2. Display settings change (value/count/scaleType via drawer) — a rectangle drawn against the old y-axis semantics doesn't map cleanly to the new one. The drawer's onSubmit now clears selection alongside the new values. --- .../Search/DBSearchHeatmapChart.tsx | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx index ecc5586a5e..6e98d9b2eb 100644 --- a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx +++ b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { parseAsFloat, parseAsString, useQueryStates } from 'nuqs'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { @@ -77,6 +77,22 @@ export function DBSearchHeatmapChart({ [onAddFilter, setFields], ); + // Clear the heatmap selection when the time range changes. The visual + // rectangle goes away on its own (uPlot re-initializes with new data), + // but without this the xMin/xMax/yMin/yMax URL params would linger and + // the delta chart would keep running its comparison query against the + // new time range. + const fromMs = chartConfig.dateRange[0].getTime(); + const toMs = chartConfig.dateRange[1].getTime(); + const prevDateRangeRef = useRef(null); + useEffect(() => { + const key = `${fromMs}-${toMs}`; + if (prevDateRangeRef.current != null && prevDateRangeRef.current !== key) { + setFields({ xMin: null, xMax: null, yMin: null, yMax: null }); + } + prevDateRangeRef.current = key; + }, [fromMs, toMs, setFields]); + return ( { + // Changing value/count/scale changes what the y-axis represents, + // so drop any existing selection — a rectangle drawn against the + // old axis doesn't map cleanly to the new one. setFields({ value: data.value, count: data.count, scaleType: data.scaleType, + xMin: null, + xMax: null, + yMin: null, + yMax: null, }); settingsHandlers.close(); }} From 4e1d46d09b442a3817cc1e7627a13c8f863b5517 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Mon, 27 Apr 2026 16:03:16 +0900 Subject: [PATCH 31/33] fix: address heatmap review feedback round 2 - Narrow `heatmapScaleType` schema from `string` to `enum(['log','linear'])`, removing the defensive ternaries in DBHeatmapChart and EditTimeChartForm that were re-validating the value at runtime. - Fix tab-switch bug: when entering Heatmap from Search, `select` was still a string when the heatmap-defaults branch checked for an array, so the defaults never applied and the query errored. Restructure the displayType- change effect so the Heatmap branch covers both string and array `select`. - Drop redundant `as` cast in HeatmapSettingsDrawer scaleType handler in favor of an inline type guard. - Add `showLegend` prop to DBHeatmapChart; enable it on the chart-editor preview and dashboard tile so the heatmap shows a compact gradient legend in the toolbar (search keeps its existing delta-chart legend). - Migrate DBSearchHeatmapChart to use `toHeatmapChartConfig` instead of building the heatmap config inline, so all three call sites share the same transform. --- packages/app/src/DBDashboardPage.tsx | 1 + .../DBEditTimeChartForm/ChartPreviewPanel.tsx | 2 +- .../DBEditTimeChartForm/EditTimeChartForm.tsx | 38 ++++++++++-------- .../app/src/components/DBHeatmapChart.tsx | 14 +++++-- .../src/components/HeatmapSettingsDrawer.tsx | 11 +++-- .../Search/DBSearchHeatmapChart.tsx | 40 +++++++++---------- packages/common-utils/src/types.ts | 2 +- 7 files changed, 60 insertions(+), 48 deletions(-) diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index ed5450e13b..3c71c02d93 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -221,6 +221,7 @@ function HeatmapTile({ toolbarPrefix={toolbar} config={heatmapConfig} scaleType={scaleType} + showLegend /> {clickPos != null && eventDeltasUrl != null && ( <> diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx index 4d3e97df58..ed84f4b5d6 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx @@ -41,7 +41,7 @@ function HeatmapPreview({ const { heatmapConfig, scaleType } = toHeatmapChartConfig(config); return (
- +
); } diff --git a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx index 8a75e62cea..71422871a1 100644 --- a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx @@ -429,9 +429,23 @@ export default function EditTimeChartForm({ if (displayType === DisplayType.Search && typeof select !== 'string') { setValue('select', ''); setValue('series', []); - } - - if (displayType !== DisplayType.Search && !Array.isArray(select)) { + } else if (displayType === DisplayType.Heatmap) { + // Two entry paths into Heatmap: + // - From Search/RawSQL: select is a string; clear `where` too + // - From another builder tab: select is already an array + const fallbackValue = Array.isArray(select) + ? (select[0]?.valueExpression ?? '') + : ''; + const defaultValue = + tableSource?.kind === SourceKind.Trace && + tableSource.durationExpression + ? getDurationMsExpression(tableSource) + : fallbackValue; + if (typeof select === 'string') { + setValue('where', ''); + } + applyHeatmapDefaults(setValue, defaultValue); + } else if (!Array.isArray(select)) { const defaultSeries: SavedChartConfigWithSelectArray['select'] = [ { aggFn: 'count', @@ -445,15 +459,6 @@ export default function EditTimeChartForm({ setValue('series', defaultSeries); } - if (displayType === DisplayType.Heatmap && Array.isArray(select)) { - const defaultValue = - tableSource?.kind === SourceKind.Trace && - tableSource.durationExpression - ? getDurationMsExpression(tableSource) - : (select[0]?.valueExpression ?? ''); - applyHeatmapDefaults(setValue, defaultValue); - } - // Don't auto-submit when config type changes, to avoid clearing form state (like source) if (displayTypeChanged) { // true = Suppress error notification (because we're auto-submitting) @@ -557,12 +562,11 @@ export default function EditTimeChartForm({ control, name: 'series.0.countExpression', }); - const heatmapScaleTypeRaw = useWatch({ - control, - name: 'series.0.heatmapScaleType', - }); const heatmapScaleType: HeatmapScaleType = - heatmapScaleTypeRaw === 'linear' ? 'linear' : 'log'; + useWatch({ + control, + name: 'series.0.heatmapScaleType', + }) ?? 'log'; const heatmapSettingsDefaults = useMemo( () => ({ diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index c8f4d5b7b7..2a13d33356 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -332,7 +332,7 @@ export function toHeatmapChartConfig(config: BuilderChartConfigWithDateRange): { granularity: 'auto', numberFormat: config.numberFormat, }, - scaleType: firstSelect?.heatmapScaleType === 'linear' ? 'linear' : 'log', + scaleType: firstSelect?.heatmapScaleType ?? 'log', }; } @@ -378,6 +378,7 @@ function HeatmapContainer({ toolbarPrefix, toolbarSuffix, scaleType = 'log', + showLegend = false, }: { config: HeatmapChartConfig; enabled?: boolean; @@ -387,6 +388,7 @@ function HeatmapContainer({ toolbarPrefix?: React.ReactNode[]; toolbarSuffix?: React.ReactNode[]; scaleType?: HeatmapScaleType; + showLegend?: boolean; }) { const dateRange = config.dateRange; const granularity = convertDateRangeToGranularityString(dateRange, 245); @@ -616,7 +618,13 @@ function HeatmapContainer({ } const toolbarItemsMemo = useMemo(() => { - const allToolbarItems = []; + const allToolbarItems: React.ReactNode[] = []; + + if (showLegend) { + allToolbarItems.push( + , + ); + } if (toolbarPrefix && toolbarPrefix.length > 0) { allToolbarItems.push(...toolbarPrefix); @@ -627,7 +635,7 @@ function HeatmapContainer({ } return allToolbarItems; - }, [toolbarPrefix, toolbarSuffix]); + }, [showLegend, palette, toolbarPrefix, toolbarSuffix]); const _error = error || minMaxError; diff --git a/packages/app/src/components/HeatmapSettingsDrawer.tsx b/packages/app/src/components/HeatmapSettingsDrawer.tsx index a3cff94a03..c643d5109f 100644 --- a/packages/app/src/components/HeatmapSettingsDrawer.tsx +++ b/packages/app/src/components/HeatmapSettingsDrawer.tsx @@ -75,12 +75,11 @@ export default function HeatmapSettingsDrawer({ - form.setValue( - 'scaleType', - v as HeatmapSettingsValues['scaleType'], - ) - } + onChange={v => { + if (v === 'log' || v === 'linear') { + form.setValue('scaleType', v); + } + }} data={[ { label: 'Log', value: 'log' }, { label: 'Linear', value: 'linear' }, diff --git a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx index 6e98d9b2eb..e618526abb 100644 --- a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx +++ b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx @@ -3,7 +3,6 @@ import { parseAsFloat, parseAsString, useQueryStates } from 'nuqs'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { BuilderChartConfigWithDateRange, - DisplayType, TTraceSource, } from '@hyperdx/common-utils/dist/types'; import { @@ -27,6 +26,7 @@ import DBHeatmapChart, { darkPalette, type HeatmapScaleType, lightPalette, + toHeatmapChartConfig, } from '../DBHeatmapChart'; export function DBSearchHeatmapChart({ @@ -111,25 +111,25 @@ export function DBSearchHeatmapChart({ }} > { diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 4b0236c1d1..77b0e22073 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -139,7 +139,7 @@ export const DerivedColumnSchema = z.intersection( metricNameSql: z.string().optional(), // Heatmap-specific fields (optional, only used when displayType is Heatmap) countExpression: z.string().optional(), - heatmapScaleType: z.string().optional(), + heatmapScaleType: z.enum(['log', 'linear']).optional(), }), ); export const SelectListSchema = z.array(DerivedColumnSchema).or(z.string()); From 51bb76186dd7d70a4d7423d3901cbeb37c602d33 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Tue, 28 Apr 2026 11:24:41 +0900 Subject: [PATCH 32/33] feat(heatmap): show bounds + bucket SQL preview, hide MV indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Heatmap runs as two sequential ClickHouse queries (a bounds query feeding min/max into a bucketed-counts query), so a single SQL preview misrepresents what actually executes. Render both queries labeled in the editor's "Generated SQL" accordion; the bucket query uses {min}/{max} placeholder tokens since those literals are only known once the bounds query returns. Also suppress the MV optimization indicator on the heatmap tab — neither of the two queries is what the editor's chart config alone would produce, so the indicator was misleading. Per Drew's review feedback on PR #2107. Refactor: extract buildHeatmapBoundsConfig / buildHeatmapBucketConfig from DBHeatmapChart's HeatmapContainer to top-level exports so the SQL preview can reuse them. No behavioral change to the live chart. --- .../ChartEditorControls.tsx | 1 + .../DBEditTimeChartForm/ChartPreviewPanel.tsx | 81 ++++++- .../__tests__/ChartPreviewPanel.test.tsx | 26 ++ .../__tests__/utils.test.ts | 3 +- .../components/DBEditTimeChartForm/utils.ts | 7 +- .../app/src/components/DBHeatmapChart.tsx | 228 +++++++++++------- 6 files changed, 246 insertions(+), 100 deletions(-) diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx index ee045ef657..6080d2aedc 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx @@ -113,6 +113,7 @@ export function ChartEditorControls({ {tableSource && activeTab !== 'search' && + activeTab !== 'heatmap' && chartConfigForExplanations && isBuilderChartConfig(chartConfigForExplanations) && ( +
+ + 1. Bounds query — resolves min/max for bucket boundaries + + +
+
+ + 2. Heatmap query — runs after bounds resolve; {'{min}'}/ + {'{max}'} are filled in at runtime + + +
+
+ ); +} + type ChartPreviewPanelProps = { queriedConfig?: ChartConfigWithDateRange; tableSource?: TSource; @@ -268,12 +327,20 @@ export function ChartPreviewPanel({ - {queryReady && chartConfigForExplanations != null && ( - - )} + {queryReady && + chartConfigForExplanations != null && + (activeTab === 'heatmap' && + isBuilderChartConfig(chartConfigForExplanations) ? ( + + ) : ( + + ))} diff --git a/packages/app/src/components/DBEditTimeChartForm/__tests__/ChartPreviewPanel.test.tsx b/packages/app/src/components/DBEditTimeChartForm/__tests__/ChartPreviewPanel.test.tsx index 3463d867cb..e2adafde8f 100644 --- a/packages/app/src/components/DBEditTimeChartForm/__tests__/ChartPreviewPanel.test.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/__tests__/ChartPreviewPanel.test.tsx @@ -32,6 +32,18 @@ jest.mock('@/components/DBSqlRowTableWithSidebar', () => ({ default: () =>
SQL Row Table
, })); +jest.mock('@/components/DBHeatmapChart', () => ({ + __esModule: true, + default: () =>
Heatmap Chart
, + toHeatmapChartConfig: (config: unknown) => ({ + heatmapConfig: config, + scaleType: 'log' as const, + }), + buildHeatmapBoundsConfig: ({ config }: { config: unknown }) => config, + buildHeatmapBucketConfig: ({ config }: { config: unknown }) => config, + HEATMAP_N_BUCKETS: 80, +})); + jest.mock('@/source', () => ({ getFirstTimestampValueExpression: jest.fn().mockReturnValue('Timestamp'), })); @@ -196,5 +208,19 @@ describe('ChartPreviewPanel', () => { screen.queryByText('Sample Matched Events'), ).not.toBeInTheDocument(); }); + + it('should label the bounds and heatmap queries when on the heatmap tab', () => { + renderPanel({ + queriedConfig: baseBuilderConfig, + chartConfigForExplanations: baseBuilderConfig, + showGeneratedSql: true, + activeTab: 'heatmap', + }); + + // Both query labels render — heatmap actually runs two sequential + // queries (bounds first, then bucketed counts). + expect(screen.getByText(/Bounds query/i)).toBeInTheDocument(); + expect(screen.getByText(/Heatmap query/i)).toBeInTheDocument(); + }); }); }); diff --git a/packages/app/src/components/DBEditTimeChartForm/__tests__/utils.test.ts b/packages/app/src/components/DBEditTimeChartForm/__tests__/utils.test.ts index 79cbd89294..67f96b4aca 100644 --- a/packages/app/src/components/DBEditTimeChartForm/__tests__/utils.test.ts +++ b/packages/app/src/components/DBEditTimeChartForm/__tests__/utils.test.ts @@ -229,11 +229,12 @@ describe('displayTypeToActiveTab', () => { // --------------------------------------------------------------------------- describe('TABS_WITH_GENERATED_SQL', () => { - it('includes table, time, number, pie', () => { + it('includes table, time, number, pie, heatmap', () => { expect(TABS_WITH_GENERATED_SQL.has('table')).toBe(true); expect(TABS_WITH_GENERATED_SQL.has('time')).toBe(true); expect(TABS_WITH_GENERATED_SQL.has('number')).toBe(true); expect(TABS_WITH_GENERATED_SQL.has('pie')).toBe(true); + expect(TABS_WITH_GENERATED_SQL.has('heatmap')).toBe(true); }); it('excludes search, markdown', () => { diff --git a/packages/app/src/components/DBEditTimeChartForm/utils.ts b/packages/app/src/components/DBEditTimeChartForm/utils.ts index 59bbf90a32..f6eb67dbc1 100644 --- a/packages/app/src/components/DBEditTimeChartForm/utils.ts +++ b/packages/app/src/components/DBEditTimeChartForm/utils.ts @@ -102,6 +102,7 @@ export const TABS_WITH_GENERATED_SQL = new Set([ 'time', 'number', 'pie', + 'heatmap', ]); export function computeDbTimeChartConfig( @@ -221,7 +222,11 @@ export function buildChartConfigForExplanations({ // Apply the transformations that child components will apply, // so that the MV optimization explanation and generated SQL preview - // are accurate. + // are accurate. Heatmap is special-cased: it actually runs as two + // sequential queries (bounds + bucketed counts) that depend on each + // other at runtime, so the SQL preview transforms `config` itself into + // both queries on render and the MV indicator is suppressed for this + // tab. Returning `config` unchanged is intentional. if (activeTab === 'time') { return convertToTimeChartConfig(config); } else if (activeTab === 'number') { diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index 2a13d33356..e860aef47d 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -336,81 +336,24 @@ export function toHeatmapChartConfig(config: BuilderChartConfigWithDateRange): { }; } -export function ColorLegend({ colors }: { colors: string[] }) { - return ( - - - Low - -
- {colors.map((color: string, i: number) => ( -
- ))} -
- - High - - - ); -} +export const HEATMAP_N_BUCKETS = 80; -export type HeatmapScaleType = 'log' | 'linear'; - -function HeatmapContainer({ +/** + * Build the bounds (min/max) ChartConfig that runs first. Result feeds + * `effectiveMin`/`max` into `buildHeatmapBucketConfig`. + */ +export function buildHeatmapBoundsConfig({ config, - enabled = true, - onFilter, - onClearFilter, - title, - toolbarPrefix, - toolbarSuffix, - scaleType = 'log', - showLegend = false, + scaleType, }: { config: HeatmapChartConfig; - enabled?: boolean; - onFilter?: (xMin: number, xMax: number, yMin: number, yMax: number) => void; - onClearFilter?: () => void; - title?: React.ReactNode; - toolbarPrefix?: React.ReactNode[]; - toolbarSuffix?: React.ReactNode[]; - scaleType?: HeatmapScaleType; - showLegend?: boolean; -}) { - const dateRange = config.dateRange; - const granularity = convertDateRangeToGranularityString(dateRange, 245); - - const { colorScheme } = useMantineColorScheme(); - const palette = colorScheme === 'light' ? lightPalette : darkPalette; - - const nBuckets = 80; - + scaleType: HeatmapScaleType; +}): BuilderChartConfigWithDateRange { const valueExpression = config.select[0].valueExpression; - const countExpression = config.select[0].countExpression ?? 'count()'; - - // When valueExpression is an aggregate like count(), we need to use a CTE to calculate the heatmap const isAggregateExpression = isAggregateFunction(valueExpression); - - // Use quantile-based lower bound to avoid near-zero outliers stretching - // the log axis. For the upper bound, use actual max() so that latency - // spikes (typically <1% of spans) remain visible — log scale already - // handles wide ranges naturally. Future: #1914 adds overflow-bucket - // indicators for smarter range clamping without hiding spikes. const qLo = scaleType === 'log' ? 0.01 : 0.001; - const minMaxConfig: BuilderChartConfigWithDateRange = isAggregateExpression + + return isAggregateExpression ? { ...config, where: '', @@ -464,30 +407,33 @@ function HeatmapContainer({ }, ], }; +} - const { - data: minMaxData, - isLoading: isMinMaxLoading, - error: minMaxError, - } = useQueriedChartConfig(minMaxConfig, { - queryKey: ['heatmap', minMaxConfig], - enabled: enabled, - }); - - const [errorModal, errorModalControls] = useDisclosure(); - - // UInt64 are returned as strings; quantile returns floats - const min = Number.parseFloat(minMaxData?.data?.[0]?.['min'] ?? '0'); - const max = Number.parseFloat(minMaxData?.data?.[0]?.['max'] ?? '0'); - - // Ensure min > 0 for log scale (log(0) is undefined). - // Cap the range to ~4 orders of magnitude so the axis isn't dominated - // by a long empty tail of near-zero outliers. - const effectiveMin = - scaleType === 'log' ? Math.max(min, max * 1e-4 || 1e-4) : min; +/** + * Build the bucketed-counts ChartConfig that runs second. `effectiveMin`/`max` + * are usually numbers (resolved from the bounds query), but accept strings so + * callers — like the editor's SQL preview — can pass placeholder tokens + * (e.g. `'{min}'`) before the bounds are known. + */ +export function buildHeatmapBucketConfig({ + config, + scaleType, + effectiveMin, + max, + granularity, + nBuckets, +}: { + config: HeatmapChartConfig; + scaleType: HeatmapScaleType; + effectiveMin: string | number; + max: string | number; + granularity: string; + nBuckets: number; +}): BuilderChartConfigWithDateRange { + const valueExpression = config.select[0].valueExpression; + const countExpression = config.select[0].countExpression ?? 'count()'; + const isAggregateExpression = isAggregateFunction(valueExpression); - // For log scale: bucket by log(value) to get log-spaced boundaries - // For linear scale: bucket by raw value (original behavior) const bucketExprAgg = scaleType === 'log' ? `widthBucket(log(greatest(toFloat64(value_calc), ${effectiveMin})), log(${effectiveMin}), log(${max}), ${nBuckets})` @@ -497,7 +443,7 @@ function HeatmapContainer({ ? `widthBucket(log(greatest(toFloat64(${valueExpression}), ${effectiveMin})), log(${effectiveMin}), log(${max}), ${nBuckets})` : `widthBucket(${valueExpression}, ${effectiveMin}, ${max}, ${nBuckets})`; - const bucketConfig: BuilderChartConfigWithDateRange = isAggregateExpression + return isAggregateExpression ? { ...config, where: '', @@ -552,6 +498,106 @@ function HeatmapContainer({ orderBy: [{ valueExpression: 'x_bucket', ordering: 'ASC' }], granularity, }; +} + +export function ColorLegend({ colors }: { colors: string[] }) { + return ( + + + Low + +
+ {colors.map((color: string, i: number) => ( +
+ ))} +
+ + High + + + ); +} + +export type HeatmapScaleType = 'log' | 'linear'; + +function HeatmapContainer({ + config, + enabled = true, + onFilter, + onClearFilter, + title, + toolbarPrefix, + toolbarSuffix, + scaleType = 'log', + showLegend = false, +}: { + config: HeatmapChartConfig; + enabled?: boolean; + onFilter?: (xMin: number, xMax: number, yMin: number, yMax: number) => void; + onClearFilter?: () => void; + title?: React.ReactNode; + toolbarPrefix?: React.ReactNode[]; + toolbarSuffix?: React.ReactNode[]; + scaleType?: HeatmapScaleType; + showLegend?: boolean; +}) { + const dateRange = config.dateRange; + const granularity = convertDateRangeToGranularityString(dateRange, 245); + + const { colorScheme } = useMantineColorScheme(); + const palette = colorScheme === 'light' ? lightPalette : darkPalette; + + const nBuckets = HEATMAP_N_BUCKETS; + + // Use quantile-based lower bound to avoid near-zero outliers stretching + // the log axis. For the upper bound, use actual max() so that latency + // spikes (typically <1% of spans) remain visible — log scale already + // handles wide ranges naturally. Future: #1914 adds overflow-bucket + // indicators for smarter range clamping without hiding spikes. + const minMaxConfig = buildHeatmapBoundsConfig({ config, scaleType }); + + const { + data: minMaxData, + isLoading: isMinMaxLoading, + error: minMaxError, + } = useQueriedChartConfig(minMaxConfig, { + queryKey: ['heatmap', minMaxConfig], + enabled: enabled, + }); + + const [errorModal, errorModalControls] = useDisclosure(); + + // UInt64 are returned as strings; quantile returns floats + const min = Number.parseFloat(minMaxData?.data?.[0]?.['min'] ?? '0'); + const max = Number.parseFloat(minMaxData?.data?.[0]?.['max'] ?? '0'); + + // Ensure min > 0 for log scale (log(0) is undefined). + // Cap the range to ~4 orders of magnitude so the axis isn't dominated + // by a long empty tail of near-zero outliers. + const effectiveMin = + scaleType === 'log' ? Math.max(min, max * 1e-4 || 1e-4) : min; + + const bucketConfig = buildHeatmapBucketConfig({ + config, + scaleType, + effectiveMin, + max, + granularity, + nBuckets, + }); const { data, isLoading, error } = useQueriedChartConfig(bucketConfig, { queryKey: ['heatmap_bucket', bucketConfig], From 6fb0c0df8b7b7e78a8426007a7acbf1a194e5f22 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Tue, 28 Apr 2026 12:57:23 +0900 Subject: [PATCH 33/33] fix(heatmap): remove unused DisplayType and formatDurationMs imports Caught by CI lint (no-unused-vars). Local --quiet eslint missed these because the rule severity differs locally. --- .../components/DBEditTimeChartForm/ChartPreviewPanel.tsx | 1 - packages/app/src/components/DBHeatmapChart.tsx | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx index 59560fbdcb..f92f475903 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx @@ -6,7 +6,6 @@ import { BuilderChartConfigWithOptTimestamp, ChartConfigWithDateRange, ChartConfigWithOptTimestamp, - DisplayType, SourceKind, TSource, } from '@hyperdx/common-utils/dist/types'; diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index e860aef47d..3995c73293 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -28,11 +28,7 @@ import { isAggregateFunction, timeBucketByGranularity } from '@/ChartUtils'; import { useQueriedChartConfig } from '@/hooks/useChartConfig'; import { NumberFormat } from '@/types'; import { FormatTime } from '@/useFormatTime'; -import { - formatDurationMs, - formatDurationMsCompact, - formatNumber, -} from '@/utils'; +import { formatDurationMsCompact, formatNumber } from '@/utils'; import ChartContainer from './charts/ChartContainer'; import { SQLPreview } from './ChartSQLPreview';