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/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 4370020943..e955dba243 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 as DashboardContainerSchema, DashboardFilter, @@ -44,6 +45,7 @@ import { SearchConditionLanguage, SourceKind, SQLInterval, + TSource, } from '@hyperdx/common-utils/dist/types'; import { ActionIcon, @@ -57,6 +59,8 @@ import { Menu, Modal, Paper, + Popover, + Portal, Stack, Text, Tooltip, @@ -77,6 +81,7 @@ import { IconPlayerPlay, IconPlus, IconRefresh, + IconSearch, IconSquaresDiagonal, IconTags, IconTrash, @@ -114,6 +119,9 @@ import useDashboardContainers, { import { calculateNextTilePosition, makeId } from '@/utils/tilePositioning'; import ChartContainer from './components/charts/ChartContainer'; +import DBHeatmapChart, { + toHeatmapChartConfig, +} from './components/DBHeatmapChart'; import { DBPieChart } from './components/DBPieChart'; import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; import OnboardingModal from './components/OnboardingModal'; @@ -126,7 +134,11 @@ import { useDashboardRefresh } from './hooks/useDashboardRefresh'; import useTileSelection from './hooks/useTileSelection'; 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'; @@ -149,6 +161,135 @@ 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, + 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 + + + + + + )} +
+ ); +} + const ReactGridLayout = WidthProvider(RGL); type MoveTarget = { @@ -658,6 +799,18 @@ const Tile = forwardRef( config={queriedConfig} /> )} + {queriedConfig?.displayType === DisplayType.Heatmap && + isBuilderChartConfig(queriedConfig) && ( + + )} {effectiveMarkdownConfig?.displayType === DisplayType.Markdown && 'markdown' in effectiveMarkdownConfig && ( @@ -873,6 +1026,7 @@ const EditTileModal = ({ onClose={handleClose} onDirtyChange={setHasUnsavedChanges} isDashboardForm + autoRun /> )} 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/ChartEditor/utils.ts b/packages/app/src/components/ChartEditor/utils.ts index 5f539566b3..696853b710 100644 --- a/packages/app/src/components/ChartEditor/utils.ts +++ b/packages/app/src/components/ChartEditor/utils.ts @@ -290,12 +290,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({ @@ -304,6 +305,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 ad2f993450..6080d2aedc 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx @@ -30,6 +30,7 @@ import { DEFAULT_TILE_ALERT } from '@/utils/alerts'; import { OnClickFormButton } from './OnClickForm/OnClickFormButton'; import { ChartSeriesEditor } from './ChartSeriesEditor'; +import { HeatmapSeriesEditor } from './HeatmapSeriesEditor'; import { TileAlertEditor } from './TileAlertEditor'; type ChartEditorControlsProps = { @@ -57,6 +58,7 @@ type ChartEditorControlsProps = { chartConfigForExplanations?: ChartConfigWithOptTimestamp; onSubmit: (suppressErrorNotification?: boolean) => void; openDisplaySettings: () => void; + openHeatmapSettings: () => void; }; export function ChartEditorControls({ @@ -84,6 +86,7 @@ export function ChartEditorControls({ chartConfigForExplanations, onSubmit, openDisplaySettings, + openHeatmapSettings, }: ChartEditorControlsProps) { return ( <> @@ -97,6 +100,11 @@ export function ChartEditorControls({ control={control} name="source" data-testid="source-selector" + allowedSourceKinds={ + displayType === DisplayType.Heatmap + ? [SourceKind.Trace] + : undefined + } sourceSchemaPreview={ } @@ -105,6 +113,7 @@ export function ChartEditorControls({ {tableSource && activeTab !== 'search' && + activeTab !== 'heatmap' && chartConfigForExplanations && isBuilderChartConfig(chartConfigForExplanations) && ( - {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 && (
+ ); +} + +/** + * Heatmap renders via two sequential ClickHouse queries — bounds first, then + * the bucketed-counts query that uses the resolved min/max. Show both, + * labeled, with placeholder tokens for the bucket-array literals (which only + * exist at runtime once the bounds query returns). + */ +function HeatmapSQLPreview({ + config, + dateRange, +}: { + config: BuilderChartConfigWithOptTimestamp; + dateRange: [Date, Date]; +}) { + if (!config.timestampValueExpression) { + return null; + } + const { heatmapConfig, scaleType } = toHeatmapChartConfig( + config as BuilderChartConfigWithDateRange, + ); + const granularity = convertDateRangeToGranularityString(dateRange, 245); + + const boundsConfig = buildHeatmapBoundsConfig({ + config: heatmapConfig, + scaleType, + }); + + const bucketConfig = buildHeatmapBucketConfig({ + config: heatmapConfig, + scaleType, + effectiveMin: '{min}', + max: '{max}', + granularity, + nBuckets: HEATMAP_N_BUCKETS, + }); + + return ( + +
+ + 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; @@ -160,6 +236,10 @@ export function ChartPreviewPanel({ />
)} + {queryReady && + queriedConfig != null && + isBuilderChartConfig(queriedConfig) && + activeTab === 'heatmap' && } {queryReady && tableSource && queriedConfig != null && @@ -246,12 +326,20 @@ export function ChartPreviewPanel({ - {queryReady && chartConfigForExplanations != null && ( - - )} + {queryReady && + chartConfigForExplanations != null && + (activeTab === 'heatmap' && + isBuilderChartConfig(chartConfigForExplanations) ? ( + + ) : ( + + ))} diff --git a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx index 1352583c36..71422871a1 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 { @@ -28,6 +34,7 @@ import { notifications } from '@mantine/notifications'; import { IconChartLine, IconChartPie, + IconGrid3x3, IconList, IconMarkdown, IconNumbers, @@ -50,12 +57,20 @@ 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'; import HDXMarkdownChart from '@/HDXMarkdownChart'; -import { getTraceDurationNumberFormat, useSource } from '@/source'; +import { + getDurationMsExpression, + getTraceDurationNumberFormat, + useSource, +} from '@/source'; import { normalizeNoOpAlertScheduleFields } from '@/utils/alerts'; import { ChartActionBar } from './ChartActionBar'; @@ -89,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, @@ -227,6 +261,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) @@ -369,6 +408,7 @@ export default function EditTimeChartForm({ const prevGranularityRef = useRef(granularity); const prevDisplayTypeRef = useRef(displayType); const prevConfigTypeRef = useRef(configType); + const prevSourceIdRef = useRef(sourceId); useEffect(() => { // Emulate the granularity picker auto-searching similar to dashboards @@ -389,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', @@ -411,7 +465,23 @@ export default function EditTimeChartForm({ onSubmit(true); } } - }, [displayType, select, setValue, onSubmit, configType]); + }, [displayType, select, setValue, onSubmit, configType, tableSource]); + + // Auto-populate heatmap defaults when source changes while in heatmap mode + useEffect(() => { + const sourceChanged = sourceId !== prevSourceIdRef.current; + prevSourceIdRef.current = sourceId; + + if ( + sourceChanged && + displayType === DisplayType.Heatmap && + tableSource?.kind === SourceKind.Trace && + tableSource.durationExpression + ) { + applyHeatmapDefaults(setValue, getDurationMsExpression(tableSource)); + onSubmit(true); + } + }, [sourceId, displayType, tableSource, setValue, onSubmit]); // Emulate the date range picker auto-searching similar to dashboards useEffect(() => { @@ -473,6 +543,40 @@ export default function EditTimeChartForm({ [setValue, onSubmit], ); + const handleUpdateHeatmapSettings = useCallback( + (data: HeatmapSettingsValues) => { + setValue('series.0.valueExpression', data.value); + setValue('series.0.countExpression', data.count || 'count()'); + setValue('series.0.heatmapScaleType', data.scaleType); + onSubmit(); + closeHeatmapSettings(); + }, + [setValue, onSubmit, closeHeatmapSettings], + ); + + const heatmapValueExpression = useWatch({ + control, + name: 'series.0.valueExpression', + }); + const heatmapCountExpression = useWatch({ + control, + name: 'series.0.countExpression', + }); + const heatmapScaleType: HeatmapScaleType = + useWatch({ + control, + name: 'series.0.heatmapScaleType', + }) ?? 'log'; + + const heatmapSettingsDefaults = useMemo( + () => ({ + value: heatmapValueExpression || '', + count: heatmapCountExpression || 'count()', + scaleType: heatmapScaleType, + }), + [heatmapValueExpression, heatmapCountExpression, heatmapScaleType], + ); + const tableConnection = useMemo( () => tcFromSource(tableSource), [tableSource], @@ -523,6 +627,12 @@ export default function EditTimeChartForm({ > Search + } + > + Heatmap + } @@ -620,6 +730,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 new file mode 100644 index 0000000000..57d03a2091 --- /dev/null +++ b/packages/app/src/components/DBEditTimeChartForm/HeatmapSeriesEditor.tsx @@ -0,0 +1,51 @@ +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 } from '@mantine/core'; + +import { ChartEditorFormState } from '@/components/ChartEditor/types'; +import SearchWhereInput from '@/components/SearchInput/SearchWhereInput'; + +type HeatmapSeriesEditorProps = { + control: Control; + setValue: UseFormSetValue; + tableSource?: TSource; + onSubmit: () => void; + onOpenDisplaySettings: () => void; +}; + +export function HeatmapSeriesEditor({ + control, + setValue, + tableSource, + onSubmit, + onOpenDisplaySettings, +}: HeatmapSeriesEditorProps) { + const connection = useMemo(() => tcFromSource(tableSource), [tableSource]); + + return ( + + + setValue('whereLanguage', lang) + } + showLabel={false} + /> + + + + + + ); +} 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 40cf487a61..f6eb67dbc1 100644 --- a/packages/app/src/components/DBEditTimeChartForm/utils.ts +++ b/packages/app/src/components/DBEditTimeChartForm/utils.ts @@ -90,6 +90,8 @@ export function displayTypeToActiveTab(displayType: DisplayType): string { return 'pie'; case DisplayType.Number: return 'number'; + case DisplayType.Heatmap: + return 'heatmap'; default: return 'time'; } @@ -100,6 +102,7 @@ export const TABS_WITH_GENERATED_SQL = new Set([ 'time', 'number', 'pie', + 'heatmap', ]); export function computeDbTimeChartConfig( @@ -219,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') { @@ -228,6 +235,8 @@ export function buildChartConfigForExplanations({ return convertToTableChartConfig(config); } else if (activeTab === 'pie') { return convertToPieChartConfig(config); + } else if (activeTab === 'heatmap') { + return config; } return config; diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index 75bf7988b2..c4009144fe 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'; @@ -28,7 +28,7 @@ import { isAggregateFunction, timeBucketByGranularity } from '@/ChartUtils'; import { useQueriedChartConfig } from '@/hooks/useChartConfig'; import { NumberFormat } from '@/types'; import { FormatTime } from '@/useFormatTime'; -import { formatDurationMs, formatNumber } from '@/utils'; +import { formatDurationMsCompact, formatNumber } from '@/utils'; import ChartContainer from './charts/ChartContainer'; import { SQLPreview } from './ChartSQLPreview'; @@ -232,6 +232,7 @@ const opt: uPlot.Options = { height: 600, mode: 2, ms: 1, + padding: [8, 8, 0, 4], legend: { show: false, }, @@ -243,10 +244,11 @@ const opt: uPlot.Options = { axes: [ { ...axis, + gap: 10, + space: 60, }, { ...axis, - size: 60, // fixed width so labels like "600ms" and "100µs" fit }, ], series: [ @@ -284,7 +286,7 @@ function buildSeriesForPalette(colors: string[]): Partial { }; } -type HeatmapChartConfig = { +export type HeatmapChartConfig = { displayType: DisplayType.Heatmap; select: [ { @@ -304,77 +306,50 @@ type HeatmapChartConfig = { with?: BuilderChartConfigWithDateRange['with']; }; -export function ColorLegend({ colors }: { colors: string[] }) { - return ( - - - Low - -
- {colors.map((color: string, i: number) => ( -
- ))} -
- - High - - - ); +/** 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; + return { + heatmapConfig: { + ...config, + displayType: DisplayType.Heatmap, + select: [ + { + aggFn: 'heatmap' as const, + valueExpression: firstSelect?.valueExpression ?? '', + countExpression: firstSelect?.countExpression, + }, + ], + granularity: 'auto', + numberFormat: config.numberFormat, + }, + scaleType: firstSelect?.heatmapScaleType ?? 'log', + }; } -export type HeatmapScaleType = 'log' | 'linear'; +export const HEATMAP_N_BUCKETS = 80; -function HeatmapContainer({ +/** + * Build the bounds (min/max) ChartConfig that runs first. Result feeds + * `effectiveMin`/`max` into `buildHeatmapBucketConfig`. + */ +export function buildHeatmapBoundsConfig({ config, - enabled = true, - onFilter, - title, - toolbarPrefix, - toolbarSuffix, - scaleType = 'log', + scaleType, }: { config: HeatmapChartConfig; - enabled?: boolean; - onFilter?: (xMin: number, xMax: number, yMin: number, yMax: number) => void; - title?: React.ReactNode; - toolbarPrefix?: React.ReactNode[]; - toolbarSuffix?: React.ReactNode[]; - scaleType?: HeatmapScaleType; -}) { - 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: '', @@ -428,30 +403,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})` @@ -461,7 +439,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: '', @@ -516,6 +494,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], @@ -582,7 +660,13 @@ function HeatmapContainer({ } const toolbarItemsMemo = useMemo(() => { - const allToolbarItems = []; + const allToolbarItems: React.ReactNode[] = []; + + if (showLegend) { + allToolbarItems.push( + , + ); + } if (toolbarPrefix && toolbarPrefix.length > 0) { allToolbarItems.push(...toolbarPrefix); @@ -593,7 +677,7 @@ function HeatmapContainer({ } return allToolbarItems; - }, [toolbarPrefix, toolbarSuffix]); + }, [showLegend, palette, toolbarPrefix, toolbarSuffix]); const _error = error || minMaxError; @@ -678,6 +762,7 @@ function HeatmapContainer({ } : undefined } + onClearFilter={onClearFilter} scaleType={scaleType} palette={palette} /> @@ -787,31 +872,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; @@ -831,6 +902,28 @@ 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; + + // 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( @@ -844,7 +937,7 @@ function Heatmap({ numberFormat?.output === 'duration' ? actualValue * (numberFormat?.factor ?? 1) * 1000 : actualValue; - return formatDurationMs(msValue); + return formatDurationMsCompact(msValue); } return numberFormat @@ -872,9 +965,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. @@ -925,14 +1036,14 @@ function Heatmap({ height, cursor: { drag: { - setScale: false, // Disable zooming - x: true, - y: true, - dist: 5, // Only trigger drag if distance is greater than 5 pixels + setScale: false, + x: hasFilter, + y: hasFilter, + dist: 5, }, - show: true, // Ensure the cursor is enabled + show: true, focus: { - prox: 100, // Proximity to the cursor line to trigger focus + prox: 100, }, }, plugins: [ @@ -975,35 +1086,27 @@ 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); }, }, }, ], }; - }, [width, height, tickFormatter, scaleType, palette]); + }, [width, height, tickFormatter, scaleType, palette, hasFilter]); return (
{ - 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; @@ -1028,6 +1141,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 && ( <> @@ -1067,10 +1186,14 @@ function Heatmap({ pointerEvents: 'none', }} > - - Click & Drag to Select Data - - + {onFilter && ( + <> + + Drag to Compare · Click to Clear + + + + )}
@@ -1087,66 +1210,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/HeatmapSettingsDrawer.tsx b/packages/app/src/components/HeatmapSettingsDrawer.tsx new file mode 100644 index 0000000000..c643d5109f --- /dev/null +++ b/packages/app/src/components/HeatmapSettingsDrawer.tsx @@ -0,0 +1,138 @@ +import { useCallback, useEffect } from 'react'; +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'; +import { + Box, + Button, + Divider, + Drawer, + Group, + SegmentedControl, + Stack, + Text, +} from '@mantine/core'; +import { IconPlayerPlay } from '@tabler/icons-react'; + +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; + +export default function HeatmapSettingsDrawer({ + opened, + onClose, + connection, + parentRef, + defaultValues, + onSubmit, +}: { + opened: boolean; + onClose: () => void; + connection: TableConnection; + parentRef?: HTMLElement | null; + defaultValues: HeatmapSettingsValues; + onSubmit: (v: HeatmapSettingsValues) => void; +}) { + const form = useForm({ + resolver: zodResolver(HeatmapSettingsSchema), + defaultValues, + }); + + useEffect(() => { + form.reset(defaultValues); + // eslint-disable-next-line react-hooks/exhaustive-deps -- form object is stable from useForm + }, [defaultValues]); + + const handleClose = useCallback(() => { + form.reset(defaultValues); + onClose(); + }, [onClose, form, defaultValues]); + + const scaleType = useWatch({ control: form.control, name: 'scaleType' }); + + return ( + +
+ + + + Scale + + { + 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 d9ab0d86a2..e618526abb 100644 --- a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx +++ b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx @@ -1,35 +1,21 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, 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, TTraceSource, } from '@hyperdx/common-utils/dist/types'; 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'; @@ -40,13 +26,9 @@ import DBHeatmapChart, { darkPalette, type HeatmapScaleType, lightPalette, + toHeatmapChartConfig, } from '../DBHeatmapChart'; -const Schema = z.object({ - value: z.string().trim().min(1), - count: z.string().trim().optional(), -}); - export function DBSearchHeatmapChart({ chartConfig, source, @@ -70,16 +52,19 @@ 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; + const heatmapSettingsDefaults = useMemo( + () => ({ + value: fields.value, + count: fields.count ?? 'count()', + scaleType, + }), + [fields.value, fields.count, scaleType], + ); + // After applying a filter, clear the heatmap selection so the delta chart // resets instead of staying in comparison mode. const handleAddFilterAndClearSelection = useCallback< @@ -92,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 ( { setFields({ xMin, xMax, yMin, yMax }); }} + onClearFilter={() => { + setFields({ xMin: null, xMax: null, yMin: null, yMax: null }); + }} /> {/* Gear icon overlaid on chart top-right */} - + { + // 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(); }} @@ -190,107 +199,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' }, - ]} - /> - - - - - - - - - - - - - - -
-
- ); -} diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index 2a68f4d940..9b39c21b69 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -900,6 +900,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 acab616ff9..ac6534dcd4 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.enum(['log', 'linear']).optional(), }), ); export const SelectListSchema = z.array(DerivedColumnSchema).or(z.string());