From 385b435b5d2c86851458e3f15144e0e40550f14d Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 01:26:10 +0000 Subject: [PATCH 01/43] feat: add filter/exclude/copy actions to Event Deltas tooltip Add interactive filter actions to the Event Deltas chart tooltips, consistent with the filtering UX in the results table and trace drilldown flyout. Users can now hover over a bar in the Event Deltas charts and click Copy, Filter, or Exclude buttons to drill down into the subset of spans defined by the hovered attribute value. - Enhance HDXBarChartTooltip with copy/filter/exclude action buttons - Use controlled tooltip state to keep tooltip interactive on hover - Export AddFilterFn type for reuse across the component chain - Wire onAddFilter from DBSearchPage -> DBSearchHeatmapChart -> DBDeltaChart Closes #1796 Co-authored-by: Alex Fedotyev --- packages/app/src/DBSearchPage.tsx | 1 + packages/app/src/components/DBDeltaChart.tsx | 173 +++++++++++++++++- .../Search/DBSearchHeatmapChart.tsx | 5 +- 3 files changed, 169 insertions(+), 10 deletions(-) diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 1f07d5622d..cb09ce3416 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -1900,6 +1900,7 @@ function DBSearchPage() { }} isReady={isReady} source={searchedSource} + onAddFilter={searchFilters.setFilterValue} /> )} diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index ea18aefb12..89668923be 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useMemo, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { withErrorBoundary } from 'react-error-boundary'; import { Bar, @@ -14,8 +14,23 @@ import { ChartConfigWithOptDateRange, Filter, } from '@hyperdx/common-utils/dist/types'; -import { Box, Code, Container, Flex, Pagination, Text } from '@mantine/core'; +import { + ActionIcon, + Box, + Code, + Container, + Flex, + Pagination, + Text, + Tooltip as MantineTooltip, +} from '@mantine/core'; import { useElementSize } from '@mantine/hooks'; +import { + IconCheck, + IconCopy, + IconFilter, + IconFilterX, +} from '@tabler/icons-react'; import { isAggregateFunction } from '@/ChartUtils'; import { useQueriedChartConfig } from '@/hooks/useChartConfig'; @@ -31,7 +46,7 @@ import { SQLPreview } from './ChartSQLPreview'; import styles from '../../styles/HDXLineChart.module.scss'; /* - * Response Data is like... + * Response Data is like... { Timestamp: "", Map: { @@ -155,12 +170,42 @@ function mergeValueStatisticsMaps( return mergedArray; } +export type AddFilterFn = ( + property: string, + value: string, + action?: 'only' | 'exclude' | 'include', +) => void; + const HDXBarChartTooltip = withErrorBoundary( memo((props: any) => { - const { active, payload, label, title } = props; + const { + active, + payload, + label, + title, + onAddFilter, + onMouseEnter, + onMouseLeave, + } = props; + const [isCopied, setIsCopied] = useState(false); + if (active && payload && payload.length) { + const copyValue = async () => { + try { + await navigator.clipboard.writeText(String(label ?? '')); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + } catch (e) { + console.error('Failed to copy:', e); + } + }; + return ( -
+
{title && ( @@ -177,6 +222,59 @@ const HDXBarChartTooltip = withErrorBoundary( {p.name}: {p.value.toFixed(2)}%
))} + + + + {isCopied ? : } + + + {onAddFilter && ( + <> + + + onAddFilter(title, String(label ?? ''), 'include') + } + > + + + + + + onAddFilter(title, String(label ?? ''), 'exclude') + } + > + + + + + )} +
); @@ -197,16 +295,60 @@ function PropertyComparisonChart({ name, outlierValueOccurences, inlierValueOccurences, + onAddFilter, }: { name: string; outlierValueOccurences: Map; inlierValueOccurences: Map; + onAddFilter?: AddFilterFn; }) { const mergedValueStatistics = mergeValueStatisticsMaps( outlierValueOccurences, inlierValueOccurences, ); + // Controlled tooltip state - keeps tooltip open while hovering over action buttons + const [tooltipState, setTooltipState] = useState<{ + active: boolean; + label: string; + payload: any[]; + }>({ active: false, label: '', payload: [] }); + const tooltipHideTimeoutRef = useRef(undefined); + + useEffect(() => { + return () => { + if (tooltipHideTimeoutRef.current) { + clearTimeout(tooltipHideTimeoutRef.current); + } + }; + }, []); + + const keepTooltipOpen = useCallback(() => { + if (tooltipHideTimeoutRef.current) { + clearTimeout(tooltipHideTimeoutRef.current); + } + }, []); + + const scheduleTooltipClose = useCallback(() => { + tooltipHideTimeoutRef.current = setTimeout(() => { + setTooltipState(prev => ({ ...prev, active: false })); + }, 150); + }, []); + + const handleChartMouseMove = useCallback( + (state: any) => { + if (state.isTooltipActive) { + keepTooltipOpen(); + setTooltipState({ + active: true, + label: state.activeLabel ?? '', + payload: state.activePayload ?? [], + }); + } + }, + [keepTooltipOpen], + ); + return (
@@ -224,6 +366,8 @@ function PropertyComparisonChart({ left: 0, bottom: 0, }} + onMouseMove={handleChartMouseMove} + onMouseLeave={scheduleTooltipClose} > {/* */} } + active={tooltipState.active} + payload={tooltipState.payload} + label={tooltipState.label} + wrapperStyle={{ pointerEvents: 'auto', zIndex: 1000 }} + content={ + + } allowEscapeViewBox={{ y: true }} /> ))} diff --git a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx index 5142db536b..d47f2867a4 100644 --- a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx +++ b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx @@ -21,7 +21,7 @@ import { IconPlayerPlay } from '@tabler/icons-react'; import { getDurationMsExpression } from '@/source'; -import DBDeltaChart from '../DBDeltaChart'; +import DBDeltaChart, { AddFilterFn } from '../DBDeltaChart'; import DBHeatmapChart from '../DBHeatmapChart'; import { SQLInlineEditorControlled } from '../SQLInlineEditor'; @@ -34,10 +34,12 @@ export function DBSearchHeatmapChart({ chartConfig, source, isReady, + onAddFilter, }: { chartConfig: ChartConfigWithDateRange; source: TSource; isReady: boolean; + onAddFilter?: AddFilterFn; }) { const [fields, setFields] = useQueryStates({ value: parseAsString.withDefault(getDurationMsExpression(source)), @@ -120,6 +122,7 @@ export function DBSearchHeatmapChart({ xMax={fields.xMax} yMin={fields.yMin} yMax={fields.yMax} + onAddFilter={onAddFilter} /> ) : ( From 4653cbeb59fefd5e1dad896b4cfb9e1ed46797c1 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 03:15:46 +0000 Subject: [PATCH 02/43] fix: correctly convert Map/Array(Map) column keys to bracket notation in Event Deltas filters flattenData() produces dot-notation keys like ResourceAttributes.service.name for Map-type columns, but ClickHouse SQL requires bracket notation for Map access: ResourceAttributes['service.name']. The dot notation causes ClickHouse to attempt tupleElement() on a Map type, which fails. Adds flattenedKeyToSqlExpression() that uses the query result meta (column name + type) to detect Map and Array(Map) columns and convert keys to valid SQL expressions. Also fixes the 0-based (JS) to 1-based (ClickHouse) array index for Array(Map) columns like Events.Attributes. This matches how other parts of the codebase handle map attribute filters: DBRowOverviewPanel uses ResourceAttributesExpression['key'] bracket notation. Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBDeltaChart.tsx | 92 ++++++++++++++++++- .../components/__tests__/DBDeltaChart.test.ts | 84 +++++++++++++++++ 2 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/components/__tests__/DBDeltaChart.test.ts diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index 89668923be..019364c798 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -45,6 +45,71 @@ import { SQLPreview } from './ChartSQLPreview'; import styles from '../../styles/HDXLineChart.module.scss'; +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function stripTypeWrappers(type: string): string { + let t = type.trim(); + let changed = true; + while (changed) { + changed = false; + if (t.startsWith('LowCardinality(') && t.endsWith(')')) { + t = t.slice('LowCardinality('.length, -1).trim(); + changed = true; + } else if (t.startsWith('Nullable(') && t.endsWith(')')) { + t = t.slice('Nullable('.length, -1).trim(); + changed = true; + } + } + return t; +} + +/** + * Converts a flattened dot-notation property key (produced by flattenData()) + * into a valid ClickHouse SQL expression for use in filter conditions. + * + * flattenData() uses JavaScript's object/array iteration, producing keys like: + * "ResourceAttributes.service.name" for Map(String, String) columns + * "Events.Attributes[0].message.type" for Array(Map(String, String)) columns + * + * These must be converted to bracket notation for ClickHouse Map access: + * "ResourceAttributes['service.name']" + * "Events.Attributes[1]['message.type']" (note: 0-based JS → 1-based CH index) + */ +export function flattenedKeyToSqlExpression( + key: string, + columnMeta: { name: string; type: string }[], +): string { + for (const col of columnMeta) { + const baseType = stripTypeWrappers(col.type); + + if (baseType.startsWith('Map(')) { + // Simple Map column: "MapCol.some.key" → "MapCol['some.key']" + if (key.startsWith(col.name + '.')) { + const mapKey = key.slice(col.name.length + 1); + return `${col.name}['${mapKey}']`; + } + } else if (baseType.startsWith('Array(')) { + const innerType = stripTypeWrappers(baseType.slice('Array('.length, -1)); + if (innerType.startsWith('Map(')) { + // Array(Map) column: "ColName[N].key" → "ColName[N+1]['key']" + // flattenData() uses 0-based JS indexing; ClickHouse SQL uses 1-based. + const pattern = new RegExp( + `^${escapeRegExp(col.name)}\\[(\\d+)\\]\\.(.+)$`, + ); + const match = key.match(pattern); + if (match) { + const chIndex = parseInt(match[1]) + 1; + const mapKey = match[2]; + return `${col.name}[${chIndex}]['${mapKey}']`; + } + } + } + } + return key; +} + /* * Response Data is like... { @@ -640,6 +705,31 @@ export default function DBDeltaChart({ }; }, [outlierData?.data, inlierData?.data]); + // Build column metadata map from query results to enable correct SQL expression + // generation when converting flattened property keys to filter expressions. + const columnMeta = useMemo( + () => + (outlierData?.meta ?? inlierData?.meta ?? []) as { + name: string; + type: string; + }[], + [outlierData?.meta, inlierData?.meta], + ); + + // Wrap onAddFilter to convert flattened dot-notation keys (from flattenData) + // into valid ClickHouse SQL expressions before passing to the filter handler. + const handleAddFilter = useCallback>( + (property, value, action) => { + if (!onAddFilter) return; + onAddFilter( + flattenedKeyToSqlExpression(property, columnMeta), + value, + action, + ); + }, + [onAddFilter, columnMeta], + ); + const [activePage, setPage] = useState(1); const { @@ -731,7 +821,7 @@ export default function DBDeltaChart({ inlierValueOccurences={ inlierValueOccurences.get(property) ?? new Map() } - onAddFilter={onAddFilter} + onAddFilter={onAddFilter ? handleAddFilter : undefined} key={property} /> ))} diff --git a/packages/app/src/components/__tests__/DBDeltaChart.test.ts b/packages/app/src/components/__tests__/DBDeltaChart.test.ts new file mode 100644 index 0000000000..5d2ea59526 --- /dev/null +++ b/packages/app/src/components/__tests__/DBDeltaChart.test.ts @@ -0,0 +1,84 @@ +import { flattenedKeyToSqlExpression } from '../DBDeltaChart'; + +describe('flattenedKeyToSqlExpression', () => { + const traceColumnMeta = [ + { name: 'Timestamp', type: 'DateTime64(9)' }, + { name: 'TraceId', type: 'String' }, + { name: 'ResourceAttributes', type: 'Map(String, String)' }, + { name: 'SpanAttributes', type: 'Map(String, String)' }, + { name: 'Events.Timestamp', type: 'Array(DateTime64(9))' }, + { name: 'Events.Name', type: 'Array(String)' }, + { name: 'Events.Attributes', type: 'Array(Map(String, String))' }, + { name: 'Links.Attributes', type: 'Array(Map(String, String))' }, + ]; + + it('converts Map column dot-notation to bracket notation', () => { + expect( + flattenedKeyToSqlExpression('ResourceAttributes.service.name', traceColumnMeta), + ).toBe("ResourceAttributes['service.name']"); + }); + + it('converts SpanAttributes dot-notation to bracket notation', () => { + expect( + flattenedKeyToSqlExpression('SpanAttributes.http.method', traceColumnMeta), + ).toBe("SpanAttributes['http.method']"); + }); + + it('converts Array(Map) dot-notation with 0-based index to 1-based bracket notation', () => { + expect( + flattenedKeyToSqlExpression('Events.Attributes[0].message.type', traceColumnMeta), + ).toBe("Events.Attributes[1]['message.type']"); + }); + + it('increments the array index from 0-based JS to 1-based ClickHouse', () => { + expect( + flattenedKeyToSqlExpression('Events.Attributes[4].key', traceColumnMeta), + ).toBe("Events.Attributes[5]['key']"); + }); + + it('handles Links.Attributes Array(Map) correctly', () => { + expect( + flattenedKeyToSqlExpression('Links.Attributes[0].some.key', traceColumnMeta), + ).toBe("Links.Attributes[1]['some.key']"); + }); + + it('returns simple columns unchanged', () => { + expect( + flattenedKeyToSqlExpression('TraceId', traceColumnMeta), + ).toBe('TraceId'); + }); + + it('returns non-map nested columns unchanged (e.g., Arrays of primitives)', () => { + expect( + flattenedKeyToSqlExpression('Events.Name[0]', traceColumnMeta), + ).toBe('Events.Name[0]'); + }); + + it('returns key unchanged when no matching column found', () => { + expect( + flattenedKeyToSqlExpression('SomeUnknownColumn.key', traceColumnMeta), + ).toBe('SomeUnknownColumn.key'); + }); + + it('handles LowCardinality(Map) wrapped types', () => { + const meta = [ + { name: 'LogAttributes', type: 'LowCardinality(Map(String, String))' }, + ]; + expect( + flattenedKeyToSqlExpression('LogAttributes.level', meta), + ).toBe("LogAttributes['level']"); + }); + + it('handles Nullable(Map) wrapped types', () => { + const meta = [{ name: 'Attrs', type: 'Nullable(Map(String, String))' }]; + expect( + flattenedKeyToSqlExpression('Attrs.some.key', meta), + ).toBe("Attrs['some.key']"); + }); + + it('returns key unchanged for empty columnMeta', () => { + expect(flattenedKeyToSqlExpression('ResourceAttributes.service.name', [])).toBe( + 'ResourceAttributes.service.name', + ); + }); +}); From 2abb1e81f42583872eedb7c4480f53d29f64af25 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:39:54 +0000 Subject: [PATCH 03/43] feat: add field filtering and hidden fields section to Event Deltas Implements field filtering to reduce noise in the Event Deltas chart view: 1. Structural denylist: ID fields (TraceId, SpanId, ParentSpanId, Links.TraceId[N], Links.SpanId[N]) and per-index timestamp arrays (Events.Timestamp[N], Links.Timestamp[N]) are hidden by default. Detection is type-based using columnMeta (String / Array(String) columns ending in Id/ID; Array(DateTime64) columns for timestamps). 2. Cardinality filter: Fields where min(outlierUniqueness, inlierUniqueness) > 0.9 AND combined sample size > 20 are hidden. Uses uniqueValues/propertyOccurences ratio derived from getPropertyStatistics output. 3. HiddenFieldsSection UI: Collapsed by default showing count + preview of first 5 names. Expanded view lists each hidden field with its unique value count and a "Show chart" toggle to inline the full PropertyComparisonChart. 4. Pagination update: Counts only visible fields. A "Showing X of Y fields" note appears near pagination when fields are hidden. Exports isIdField, isTimestampArrayField, isDenylisted, isHighCardinality for unit testing. Unit tests added covering all helpers and the split logic. Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBDeltaChart.tsx | 403 +++++++++++++++--- .../components/__tests__/DBDeltaChart.test.ts | 308 ++++++++++++- 2 files changed, 638 insertions(+), 73 deletions(-) diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index 019364c798..c28cda9aa3 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -110,6 +110,119 @@ export function flattenedKeyToSqlExpression( return key; } +/** + * Returns true if the field is a structural ID field that should always be hidden. + * + * Matches: + * - Top-level String columns whose name ends in "Id" or "ID" (e.g., TraceId, SpanId) + * - Array(String) column elements or plain column references whose name ends in + * "Id" or "ID" (e.g., Links.TraceId[0] from a Links.TraceId Array(String) column) + */ +export function isIdField( + key: string, + columnMeta: { name: string; type: string }[], +): boolean { + // Extract base column name: + // "ColName[N]" → colName is "ColName" + // "ColName" (no brackets) → colName is the key itself + // "ColName[N].subkey" → has brackets but doesn't end with ], skip + const arrMatch = key.match(/^([^\[]+)\[(\d+)\]$/); + const colName = arrMatch ? arrMatch[1] : key.includes('[') ? null : key; + if (!colName) return false; + if (!/(Id|ID)$/.test(colName)) return false; + + const col = columnMeta.find(c => c.name === colName); + if (!col) return false; + const baseType = stripTypeWrappers(col.type); + if (baseType === 'String') return true; + if (baseType.startsWith('Array(')) { + const innerType = stripTypeWrappers(baseType.slice('Array('.length, -1)); + return innerType === 'String'; + } + return false; +} + +/** + * Returns true if the field is a per-index timestamp array element (e.g., + * Events.Timestamp[0]) from a column of type Array(DateTime64(...)), or the + * plain column reference itself (e.g., Events.Timestamp). + */ +export function isTimestampArrayField( + key: string, + columnMeta: { name: string; type: string }[], +): boolean { + const arrMatch = key.match(/^([^\[]+)\[(\d+)\]$/); + const colName = arrMatch ? arrMatch[1] : key.includes('[') ? null : key; + if (!colName) return false; + + const col = columnMeta.find(c => c.name === colName); + if (!col) return false; + const baseType = stripTypeWrappers(col.type); + if (!baseType.startsWith('Array(')) return false; + const innerType = stripTypeWrappers(baseType.slice('Array('.length, -1)); + return innerType.startsWith('DateTime64('); +} + +/** + * Returns true if the field should always be hidden per the structural denylist: + * - ID fields (TraceId, SpanId, ParentSpanId, Links.TraceId[N], Links.SpanId[N], etc.) + * - Per-index timestamp array elements (Events.Timestamp[N], Links.Timestamp[N], etc.) + */ +export function isDenylisted( + key: string, + columnMeta: { name: string; type: string }[], +): boolean { + return isIdField(key, columnMeta) || isTimestampArrayField(key, columnMeta); +} + +/** + * Returns true if the field should be hidden due to high cardinality (most values are + * unique, meaning it provides little analytical value in the comparison view). + * + * Takes the percentage occurrence maps (value → percentage 0–100) produced by + * getPropertyStatistics, and the raw property occurrence counts. Unique value count is + * derived from the map's size. + * + * A field is considered high cardinality when: + * min(outlierUniqueness, inlierUniqueness) > 0.9 AND combined sample size > 20 + * + * "min" ensures that if either group clusters (low cardinality), the field is kept visible. + * If only one group has data, that group's uniqueness alone is used. + */ +export function isHighCardinality( + key: string, + outlierValueOccurences: Map>, + inlierValueOccurences: Map>, + outlierPropertyOccurences: Map, + inlierPropertyOccurences: Map, +): boolean { + const outlierTotal = outlierPropertyOccurences.get(key) ?? 0; + const inlierTotal = inlierPropertyOccurences.get(key) ?? 0; + const combinedSampleSize = outlierTotal + inlierTotal; + if (combinedSampleSize <= 20) return false; + + const outlierUniqueValues = outlierValueOccurences.get(key)?.size ?? 0; + const inlierUniqueValues = inlierValueOccurences.get(key)?.size ?? 0; + + const outlierUniqueness = + outlierTotal > 0 ? outlierUniqueValues / outlierTotal : null; + const inlierUniqueness = + inlierTotal > 0 ? inlierUniqueValues / inlierTotal : null; + + let effectiveUniqueness: number; + if (outlierUniqueness !== null && inlierUniqueness !== null) { + effectiveUniqueness = Math.min(outlierUniqueness, inlierUniqueness); + } else if (outlierUniqueness !== null) { + effectiveUniqueness = outlierUniqueness; + } else if (inlierUniqueness !== null) { + effectiveUniqueness = inlierUniqueness; + } else { + return false; + } + + return effectiveUniqueness > 0.9; +} + /* * Response Data is like... { @@ -200,10 +313,8 @@ function getPropertyStatistics(data: Record[]) { }); return { - // valueOccurences, percentageOccurences, - // commonProperties, - // propertyOccurences, + propertyOccurences, }; } @@ -475,6 +586,120 @@ function PropertyComparisonChart({ ); } +type HiddenProperty = { + key: string; + reason: 'denylist' | 'cardinality'; + uniqueValues: number; +}; + +function HiddenFieldsSection({ + hiddenProperties, + outlierValueOccurences, + inlierValueOccurences, + onAddFilter, +}: { + hiddenProperties: HiddenProperty[]; + outlierValueOccurences: Map>; + inlierValueOccurences: Map>; + onAddFilter?: AddFilterFn; +}) { + const [isExpanded, setIsExpanded] = useState(false); + const [expandedCharts, setExpandedCharts] = useState>(new Set()); + + if (hiddenProperties.length === 0) return null; + + const toggleChart = (key: string) => { + setExpandedCharts(prev => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; + + const previewNames = hiddenProperties + .slice(0, 5) + .map(f => f.key) + .join(' · '); + const hasMorePreview = hiddenProperties.length > 5; + + return ( + + setIsExpanded(prev => !prev)} + > + + {isExpanded ? '▼' : '▶'} {hiddenProperties.length} hidden fields + (high cardinality) + + {!isExpanded && ( + + {previewNames} + {hasMorePreview ? ' ...' : ''} + + )} + + {isExpanded && ( + + {hiddenProperties.map(({ key, uniqueValues }) => ( + + + + {key} + + + + — {uniqueValues} unique values + + toggleChart(key)} + > + {expandedCharts.has(key) ? 'Hide chart' : 'Show chart'} + + + + {expandedCharts.has(key) && ( + + )} + + ))} + + )} + + ); +} + // Layout constants for dynamic grid calculation. // CHART_WIDTH is the minimum chart width used to determine how many columns fit; actual rendered // width expands to fill the container (charts use width: '100%' inside a CSS grid). @@ -658,63 +883,102 @@ export default function DBDeltaChart({ limit: { limit: 1000 }, }); - // TODO: Is loading state - const { sortedProperties, outlierValueOccurences, inlierValueOccurences } = - useMemo(() => { - const { percentageOccurences: outlierValueOccurences } = - getPropertyStatistics(outlierData?.data ?? []); + // Compute column metadata, property statistics, sorted/visible/hidden property lists. + // columnMeta is merged here (instead of a separate useMemo) so the denylist and + // cardinality checks can reference it during the same memoization pass. + const { + sortedProperties, + outlierValueOccurences, + inlierValueOccurences, + columnMeta, + visibleProperties, + hiddenProperties, + } = useMemo(() => { + const columnMeta = (outlierData?.meta ?? inlierData?.meta ?? []) as { + name: string; + type: string; + }[]; - const { percentageOccurences: inlierValueOccurences } = - getPropertyStatistics(inlierData?.data ?? []); + const { + percentageOccurences: outlierValueOccurences, + propertyOccurences: outlierPropertyOccurences, + } = getPropertyStatistics(outlierData?.data ?? []); - // Get all the unique keys from the outliers - let uniqueKeys = new Set([...outlierValueOccurences.keys()]); - // If there's no outliers, use inliers as the unique keys - if (uniqueKeys.size === 0) { - uniqueKeys = new Set([...inlierValueOccurences.keys()]); - } - // Now process the keys to find the ones with the highest delta between outlier and inlier percentages - const sortedProperties = Array.from(uniqueKeys) - .map(key => { - const inlierCount = - inlierValueOccurences.get(key) ?? new Map(); - const outlierCount = - outlierValueOccurences.get(key) ?? new Map(); - - const mergedArray = mergeValueStatisticsMaps( - outlierCount, - inlierCount, - ); - let maxValueDelta = 0; - mergedArray.forEach(item => { - const delta = Math.abs(item.outlierCount - item.inlierCount); - if (delta > maxValueDelta) { - maxValueDelta = delta; - } - }); + const { + percentageOccurences: inlierValueOccurences, + propertyOccurences: inlierPropertyOccurences, + } = getPropertyStatistics(inlierData?.data ?? []); - return [key, maxValueDelta] as const; - }) - .sort((a, b) => b[1] - a[1]) - .map(a => a[0]); + // Get all the unique keys from the outliers + let uniqueKeys = new Set([...outlierValueOccurences.keys()]); + // If there's no outliers, use inliers as the unique keys + if (uniqueKeys.size === 0) { + uniqueKeys = new Set([...inlierValueOccurences.keys()]); + } + // Now process the keys to find the ones with the highest delta between outlier and inlier percentages + const sortedProperties = Array.from(uniqueKeys) + .map(key => { + const inlierCount = + inlierValueOccurences.get(key) ?? new Map(); + const outlierCount = + outlierValueOccurences.get(key) ?? new Map(); - return { - sortedProperties, - outlierValueOccurences, - inlierValueOccurences, - }; - }, [outlierData?.data, inlierData?.data]); - - // Build column metadata map from query results to enable correct SQL expression - // generation when converting flattened property keys to filter expressions. - const columnMeta = useMemo( - () => - (outlierData?.meta ?? inlierData?.meta ?? []) as { - name: string; - type: string; - }[], - [outlierData?.meta, inlierData?.meta], - ); + const mergedArray = mergeValueStatisticsMaps( + outlierCount, + inlierCount, + ); + let maxValueDelta = 0; + mergedArray.forEach(item => { + const delta = Math.abs(item.outlierCount - item.inlierCount); + if (delta > maxValueDelta) { + maxValueDelta = delta; + } + }); + + return [key, maxValueDelta] as const; + }) + .sort((a, b) => b[1] - a[1]) + .map(a => a[0]); + + // Split properties into visible (shown in charts) and hidden (denylist or high cardinality) + const visibleProperties: string[] = []; + const hiddenProperties: HiddenProperty[] = []; + + sortedProperties.forEach(key => { + if (isDenylisted(key, columnMeta)) { + const uniqueValues = Math.max( + outlierValueOccurences.get(key)?.size ?? 0, + inlierValueOccurences.get(key)?.size ?? 0, + ); + hiddenProperties.push({ key, reason: 'denylist', uniqueValues }); + } else if ( + isHighCardinality( + key, + outlierValueOccurences, + inlierValueOccurences, + outlierPropertyOccurences, + inlierPropertyOccurences, + ) + ) { + const uniqueValues = Math.max( + outlierValueOccurences.get(key)?.size ?? 0, + inlierValueOccurences.get(key)?.size ?? 0, + ); + hiddenProperties.push({ key, reason: 'cardinality', uniqueValues }); + } else { + visibleProperties.push(key); + } + }); + + return { + sortedProperties, + outlierValueOccurences, + inlierValueOccurences, + columnMeta, + visibleProperties, + hiddenProperties, + }; + }, [outlierData, inlierData]); // Wrap onAddFilter to convert flattened dot-notation keys (from flattenData) // into valid ClickHouse SQL expressions before passing to the filter handler. @@ -790,14 +1054,15 @@ export default function DBDeltaChart({ ); } - const totalPages = Math.ceil(sortedProperties.length / PAGE_SIZE); + const totalPages = Math.ceil(visibleProperties.length / PAGE_SIZE); return ( - {sortedProperties + {visibleProperties .slice((activePage - 1) * PAGE_SIZE, activePage * PAGE_SIZE) .map(property => ( 1 ? 'visible' : 'hidden', + visibility: + totalPages > 1 || hiddenProperties.length > 0 + ? 'visible' + : 'hidden', }} > + + {hiddenProperties.length > 0 + ? `Showing ${visibleProperties.length} of ${sortedProperties.length} fields` + : ' '} + 1 ? 'visible' : 'hidden' }} /> + ); } diff --git a/packages/app/src/components/__tests__/DBDeltaChart.test.ts b/packages/app/src/components/__tests__/DBDeltaChart.test.ts index 5d2ea59526..b2ee02ec8a 100644 --- a/packages/app/src/components/__tests__/DBDeltaChart.test.ts +++ b/packages/app/src/components/__tests__/DBDeltaChart.test.ts @@ -1,17 +1,28 @@ -import { flattenedKeyToSqlExpression } from '../DBDeltaChart'; +import { + flattenedKeyToSqlExpression, + isDenylisted, + isHighCardinality, + isIdField, + isTimestampArrayField, +} from '../DBDeltaChart'; -describe('flattenedKeyToSqlExpression', () => { - const traceColumnMeta = [ - { name: 'Timestamp', type: 'DateTime64(9)' }, - { name: 'TraceId', type: 'String' }, - { name: 'ResourceAttributes', type: 'Map(String, String)' }, - { name: 'SpanAttributes', type: 'Map(String, String)' }, - { name: 'Events.Timestamp', type: 'Array(DateTime64(9))' }, - { name: 'Events.Name', type: 'Array(String)' }, - { name: 'Events.Attributes', type: 'Array(Map(String, String))' }, - { name: 'Links.Attributes', type: 'Array(Map(String, String))' }, - ]; +const traceColumnMeta = [ + { name: 'Timestamp', type: 'DateTime64(9)' }, + { name: 'TraceId', type: 'String' }, + { name: 'SpanId', type: 'String' }, + { name: 'ParentSpanId', type: 'String' }, + { name: 'ResourceAttributes', type: 'Map(String, String)' }, + { name: 'SpanAttributes', type: 'Map(String, String)' }, + { name: 'Events.Timestamp', type: 'Array(DateTime64(9))' }, + { name: 'Events.Name', type: 'Array(String)' }, + { name: 'Events.Attributes', type: 'Array(Map(String, String))' }, + { name: 'Links.TraceId', type: 'Array(String)' }, + { name: 'Links.SpanId', type: 'Array(String)' }, + { name: 'Links.Timestamp', type: 'Array(DateTime64(9))' }, + { name: 'Links.Attributes', type: 'Array(Map(String, String))' }, +]; +describe('flattenedKeyToSqlExpression', () => { it('converts Map column dot-notation to bracket notation', () => { expect( flattenedKeyToSqlExpression('ResourceAttributes.service.name', traceColumnMeta), @@ -82,3 +93,276 @@ describe('flattenedKeyToSqlExpression', () => { ); }); }); + +describe('isIdField', () => { + it('identifies top-level String columns ending in Id', () => { + expect(isIdField('TraceId', traceColumnMeta)).toBe(true); + expect(isIdField('SpanId', traceColumnMeta)).toBe(true); + expect(isIdField('ParentSpanId', traceColumnMeta)).toBe(true); + }); + + it('identifies Array(String) column elements whose name ends in Id', () => { + expect(isIdField('Links.TraceId[0]', traceColumnMeta)).toBe(true); + expect(isIdField('Links.SpanId[0]', traceColumnMeta)).toBe(true); + expect(isIdField('Links.TraceId[5]', traceColumnMeta)).toBe(true); + }); + + it('identifies plain Array(String) column reference ending in Id', () => { + expect(isIdField('Links.TraceId', traceColumnMeta)).toBe(true); + expect(isIdField('Links.SpanId', traceColumnMeta)).toBe(true); + }); + + it('does not match non-ID String columns', () => { + expect(isIdField('Timestamp', traceColumnMeta)).toBe(false); + expect(isIdField('Events.Name[0]', traceColumnMeta)).toBe(false); + }); + + it('does not match Map or Array(Map) columns even if name ends in Id', () => { + const meta = [{ name: 'MyMapId', type: 'Map(String, String)' }]; + expect(isIdField('MyMapId', meta)).toBe(false); + }); + + it('does not match keys with sub-keys after array index (Array(Map) paths)', () => { + expect(isIdField('Events.Attributes[0].spanId', traceColumnMeta)).toBe(false); + }); + + it('returns false for unknown columns', () => { + expect(isIdField('UnknownId', traceColumnMeta)).toBe(false); + }); + + it('returns false for empty columnMeta', () => { + expect(isIdField('TraceId', [])).toBe(false); + }); +}); + +describe('isTimestampArrayField', () => { + it('identifies Array(DateTime64) column elements by index', () => { + expect(isTimestampArrayField('Events.Timestamp[0]', traceColumnMeta)).toBe(true); + expect(isTimestampArrayField('Events.Timestamp[23]', traceColumnMeta)).toBe(true); + expect(isTimestampArrayField('Links.Timestamp[0]', traceColumnMeta)).toBe(true); + }); + + it('identifies plain Array(DateTime64) column reference', () => { + expect(isTimestampArrayField('Events.Timestamp', traceColumnMeta)).toBe(true); + expect(isTimestampArrayField('Links.Timestamp', traceColumnMeta)).toBe(true); + }); + + it('does not match non-DateTime64 array columns', () => { + expect(isTimestampArrayField('Events.Name[0]', traceColumnMeta)).toBe(false); + expect(isTimestampArrayField('Links.TraceId[0]', traceColumnMeta)).toBe(false); + expect(isTimestampArrayField('Events.Attributes[0]', traceColumnMeta)).toBe(false); + }); + + it('does not match non-array DateTime64 columns', () => { + expect(isTimestampArrayField('Timestamp', traceColumnMeta)).toBe(false); + }); + + it('does not match Array(Map) sub-key paths', () => { + expect(isTimestampArrayField('Events.Attributes[0].timestamp', traceColumnMeta)).toBe(false); + }); + + it('returns false for unknown columns', () => { + expect(isTimestampArrayField('Unknown.Timestamp[0]', traceColumnMeta)).toBe(false); + }); + + it('handles Array(DateTime64) with timezone parameter', () => { + const meta = [{ name: 'MyTimestamps', type: "Array(DateTime64(9, 'UTC'))" }]; + expect(isTimestampArrayField('MyTimestamps[0]', meta)).toBe(true); + }); +}); + +describe('isDenylisted', () => { + it('denylists ID fields', () => { + expect(isDenylisted('TraceId', traceColumnMeta)).toBe(true); + expect(isDenylisted('SpanId', traceColumnMeta)).toBe(true); + expect(isDenylisted('ParentSpanId', traceColumnMeta)).toBe(true); + expect(isDenylisted('Links.TraceId[0]', traceColumnMeta)).toBe(true); + }); + + it('denylists timestamp array fields', () => { + expect(isDenylisted('Events.Timestamp[0]', traceColumnMeta)).toBe(true); + expect(isDenylisted('Links.Timestamp[3]', traceColumnMeta)).toBe(true); + }); + + it('does not denylist useful fields', () => { + expect(isDenylisted('ResourceAttributes.service.name', traceColumnMeta)).toBe(false); + expect(isDenylisted('SpanAttributes.http.method', traceColumnMeta)).toBe(false); + expect(isDenylisted('Events.Name[0]', traceColumnMeta)).toBe(false); + }); +}); + +describe('isHighCardinality', () => { + it('identifies high cardinality fields (all unique values)', () => { + // 1000 unique values out of 1000 total occurrences + const outlierValues = new Map(); + for (let i = 0; i < 1000; i++) { + outlierValues.set(`value-${i}`, 0.1); + } + const outlierValueOccurences = new Map([['TraceId', outlierValues]]); + const outlierPropertyOccurences = new Map([['TraceId', 1000]]); + + expect( + isHighCardinality( + 'TraceId', + outlierValueOccurences, + new Map(), + outlierPropertyOccurences, + new Map(), + ), + ).toBe(true); + }); + + it('keeps low cardinality fields visible (few distinct values)', () => { + const outlierValues = new Map([['GET', 80], ['POST', 20]]); + const outlierValueOccurences = new Map([['http.method', outlierValues]]); + const outlierPropertyOccurences = new Map([['http.method', 1000]]); + + expect( + isHighCardinality( + 'http.method', + outlierValueOccurences, + new Map(), + outlierPropertyOccurences, + new Map(), + ), + ).toBe(false); + }); + + it('uses min of both groups — keeps visible if either group has low cardinality', () => { + // Outliers: 2 unique values (low cardinality) + const outlierValues = new Map([['GET', 80], ['POST', 20]]); + const outlierValueOccurences = new Map([['method', outlierValues]]); + const outlierPropertyOccurences = new Map([['method', 1000]]); + + // Inliers: 500 unique values (high cardinality) + const inlierValues = new Map(); + for (let i = 0; i < 500; i++) inlierValues.set(`v${i}`, 0.2); + const inlierValueOccurences = new Map([['method', inlierValues]]); + const inlierPropertyOccurences = new Map([['method', 500]]); + + // outlierUniqueness = 2/1000 = 0.002, inlierUniqueness = 500/500 = 1.0 + // min = 0.002 < 0.9 → field is visible + expect( + isHighCardinality( + 'method', + outlierValueOccurences, + inlierValueOccurences, + outlierPropertyOccurences, + inlierPropertyOccurences, + ), + ).toBe(false); + }); + + it('hides field when BOTH groups have high cardinality', () => { + const makeHighCardinalityMap = (n: number) => { + const m = new Map(); + for (let i = 0; i < n; i++) m.set(`v${i}`, 100 / n); + return m; + }; + + const outlierValues = makeHighCardinalityMap(500); + const inlierValues = makeHighCardinalityMap(400); + const outlierValueOccurences = new Map([['url', outlierValues]]); + const inlierValueOccurences = new Map([['url', inlierValues]]); + const outlierPropertyOccurences = new Map([['url', 500]]); + const inlierPropertyOccurences = new Map([['url', 400]]); + + // min(500/500, 400/400) = 1.0 > 0.9 → hidden + expect( + isHighCardinality( + 'url', + outlierValueOccurences, + inlierValueOccurences, + outlierPropertyOccurences, + inlierPropertyOccurences, + ), + ).toBe(true); + }); + + it('keeps visible when combined sample size is <= 20', () => { + const outlierValues = new Map(); + for (let i = 0; i < 10; i++) outlierValues.set(`v${i}`, 10); + const outlierValueOccurences = new Map([['field', outlierValues]]); + const outlierPropertyOccurences = new Map([['field', 10]]); + const inlierPropertyOccurences = new Map([['field', 10]]); + + // combined = 10 + 10 = 20, threshold is > 20 + expect( + isHighCardinality( + 'field', + outlierValueOccurences, + new Map(), + outlierPropertyOccurences, + inlierPropertyOccurences, + ), + ).toBe(false); + }); + + it('uses single group uniqueness when other group has no data', () => { + // Only outlier data, high cardinality + const outlierValues = new Map(); + for (let i = 0; i < 100; i++) outlierValues.set(`v${i}`, 1); + const outlierValueOccurences = new Map([['id', outlierValues]]); + const outlierPropertyOccurences = new Map([['id', 100]]); + + expect( + isHighCardinality( + 'id', + outlierValueOccurences, + new Map(), + outlierPropertyOccurences, + new Map(), + ), + ).toBe(true); + }); + + it('returns false for field not present in either group', () => { + expect( + isHighCardinality( + 'unknownField', + new Map(), + new Map(), + new Map(), + new Map(), + ), + ).toBe(false); + }); +}); + +describe('field split logic (visible vs hidden)', () => { + it('correctly classifies a mix of ID, timestamp, cardinality, and useful fields', () => { + // TraceId → denylist (ID field, String) + expect(isDenylisted('TraceId', traceColumnMeta)).toBe(true); + + // Events.Timestamp[0] → denylist (timestamp array) + expect(isDenylisted('Events.Timestamp[0]', traceColumnMeta)).toBe(true); + + // ResourceAttributes.service.name → not denylisted + expect(isDenylisted('ResourceAttributes.service.name', traceColumnMeta)).toBe(false); + + // High cardinality field with 1000 unique values in 1000 rows → hidden + const hcValues = new Map(); + for (let i = 0; i < 1000; i++) hcValues.set(`trace-${i}`, 0.1); + expect( + isHighCardinality( + 'trace.id', + new Map([['trace.id', hcValues]]), + new Map(), + new Map([['trace.id', 1000]]), + new Map(), + ), + ).toBe(true); + + // Low cardinality field → visible + const lcValues = new Map([['production', 70], ['staging', 30]]); + expect( + isHighCardinality( + 'deployment.env', + new Map([['deployment.env', lcValues]]), + new Map(), + new Map([['deployment.env', 1000]]), + new Map(), + ), + ).toBe(false); + }); +}); From 04f30b2ea71690e689511d3463bb020254bb60f0 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:02:59 +0000 Subject: [PATCH 04/43] refactor: move lower-priority fields to last page within existing pagination Replace the HiddenFieldsSection component (which rendered below pagination) with an inline section that appears on the last page. Lower-priority fields use the same PropertyComparisonChart component and same CSS grid layout as primary fields. A Divider with label 'Lower-priority fields (N)' provides a clear visual and logical separator. No expand/collapse -- fields are always visible on the last page as static content. Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBDeltaChart.tsx | 190 +++++-------------- 1 file changed, 46 insertions(+), 144 deletions(-) diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index c28cda9aa3..53549478fb 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -19,6 +19,7 @@ import { Box, Code, Container, + Divider, Flex, Pagination, Text, @@ -586,120 +587,6 @@ function PropertyComparisonChart({ ); } -type HiddenProperty = { - key: string; - reason: 'denylist' | 'cardinality'; - uniqueValues: number; -}; - -function HiddenFieldsSection({ - hiddenProperties, - outlierValueOccurences, - inlierValueOccurences, - onAddFilter, -}: { - hiddenProperties: HiddenProperty[]; - outlierValueOccurences: Map>; - inlierValueOccurences: Map>; - onAddFilter?: AddFilterFn; -}) { - const [isExpanded, setIsExpanded] = useState(false); - const [expandedCharts, setExpandedCharts] = useState>(new Set()); - - if (hiddenProperties.length === 0) return null; - - const toggleChart = (key: string) => { - setExpandedCharts(prev => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - return next; - }); - }; - - const previewNames = hiddenProperties - .slice(0, 5) - .map(f => f.key) - .join(' · '); - const hasMorePreview = hiddenProperties.length > 5; - - return ( - - setIsExpanded(prev => !prev)} - > - - {isExpanded ? '▼' : '▶'} {hiddenProperties.length} hidden fields - (high cardinality) - - {!isExpanded && ( - - {previewNames} - {hasMorePreview ? ' ...' : ''} - - )} - - {isExpanded && ( - - {hiddenProperties.map(({ key, uniqueValues }) => ( - - - - {key} - - - - — {uniqueValues} unique values - - toggleChart(key)} - > - {expandedCharts.has(key) ? 'Hide chart' : 'Show chart'} - - - - {expandedCharts.has(key) && ( - - )} - - ))} - - )} - - ); -} - // Layout constants for dynamic grid calculation. // CHART_WIDTH is the minimum chart width used to determine how many columns fit; actual rendered // width expands to fill the container (charts use width: '100%' inside a CSS grid). @@ -887,7 +774,6 @@ export default function DBDeltaChart({ // columnMeta is merged here (instead of a separate useMemo) so the denylist and // cardinality checks can reference it during the same memoization pass. const { - sortedProperties, outlierValueOccurences, inlierValueOccurences, columnMeta, @@ -942,15 +828,11 @@ export default function DBDeltaChart({ // Split properties into visible (shown in charts) and hidden (denylist or high cardinality) const visibleProperties: string[] = []; - const hiddenProperties: HiddenProperty[] = []; + const hiddenProperties: string[] = []; sortedProperties.forEach(key => { if (isDenylisted(key, columnMeta)) { - const uniqueValues = Math.max( - outlierValueOccurences.get(key)?.size ?? 0, - inlierValueOccurences.get(key)?.size ?? 0, - ); - hiddenProperties.push({ key, reason: 'denylist', uniqueValues }); + hiddenProperties.push(key); } else if ( isHighCardinality( key, @@ -960,18 +842,13 @@ export default function DBDeltaChart({ inlierPropertyOccurences, ) ) { - const uniqueValues = Math.max( - outlierValueOccurences.get(key)?.size ?? 0, - inlierValueOccurences.get(key)?.size ?? 0, - ); - hiddenProperties.push({ key, reason: 'cardinality', uniqueValues }); + hiddenProperties.push(key); } else { visibleProperties.push(key); } }); return { - sortedProperties, outlierValueOccurences, inlierValueOccurences, columnMeta, @@ -1056,6 +933,11 @@ export default function DBDeltaChart({ const totalPages = Math.ceil(visibleProperties.length / PAGE_SIZE); + // Show lower-priority fields on the last page (or when there are no visible fields) + const showLowerPriorityFields = + hiddenProperties.length > 0 && + (totalPages === 0 || activePage === totalPages); + return ( ))}
+ {showLowerPriorityFields && ( + <> + + Lower-priority fields ({hiddenProperties.length}) + + } + labelPosition="left" + /> +
+ {hiddenProperties.map(key => ( + + ))} +
+ + )} 1 || hiddenProperties.length > 0 - ? 'visible' - : 'hidden', + visibility: totalPages > 1 ? 'visible' : 'hidden', }} > - - {hiddenProperties.length > 0 - ? `Showing ${visibleProperties.length} of ${sortedProperties.length} fields` - : ' '} - 1 ? 'visible' : 'hidden' }} /> - ); } From b62f7ea9099982a301890b3547a35624d21f7668 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:09:29 +0000 Subject: [PATCH 05/43] feat: improve Event Deltas chart tooltip and legend UX - Split hover/click interactions: hover tooltip is now info-only (value name + percentages), click opens an anchored action popover - Click popover (position:fixed, anchored to bar click coordinates) shows Filter -> Exclude -> Copy buttons; Filter/Exclude dismiss popover on action, Copy keeps it open with Copied! feedback - Popover dismisses on click-outside and scroll - Added TruncatedTick custom XAxis tick: truncates to 12 chars + ..., includes native SVG title for full value on hover - Top-6 + Other(N) aggregation: fields with >6 distinct values show top 6 by combined frequency and collapse the rest into a neutral gray Other(N) bucket; click actions disabled on Other bar - Button order: Filter (primary variant) -> Exclude (secondary) -> Copy (secondary, icon-only) - consistent with codebase action patterns - Removed controlled tooltip hover-delay state - Exported applyTopNAggregation and MAX_CHART_VALUES for unit testing - Added unit tests for applyTopNAggregation (7 test cases) Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBDeltaChart.tsx | 362 +++++++++++------- .../components/__tests__/DBDeltaChart.test.ts | 77 ++++ 2 files changed, 301 insertions(+), 138 deletions(-) diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index 53549478fb..9397075e1c 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -3,6 +3,7 @@ import { withErrorBoundary } from 'react-error-boundary'; import { Bar, BarChart, + Cell, ResponsiveContainer, Tooltip, XAxis, @@ -353,36 +354,15 @@ export type AddFilterFn = ( action?: 'only' | 'exclude' | 'include', ) => void; +// Hover-only tooltip: shows value name and percentages, no action buttons. +// Actions are handled by the click popover in PropertyComparisonChart. const HDXBarChartTooltip = withErrorBoundary( memo((props: any) => { - const { - active, - payload, - label, - title, - onAddFilter, - onMouseEnter, - onMouseLeave, - } = props; - const [isCopied, setIsCopied] = useState(false); + const { active, payload, label, title } = props; if (active && payload && payload.length) { - const copyValue = async () => { - try { - await navigator.clipboard.writeText(String(label ?? '')); - setIsCopied(true); - setTimeout(() => setIsCopied(false), 2000); - } catch (e) { - console.error('Failed to copy:', e); - } - }; - return ( -
+
{title && ( @@ -390,7 +370,7 @@ const HDXBarChartTooltip = withErrorBoundary( )} - {label.length === 0 ? Empty String : label} + {String(label).length === 0 ? Empty String : String(label)} {payload .sort((a: any, b: any) => b.value - a.value) @@ -399,59 +379,6 @@ const HDXBarChartTooltip = withErrorBoundary( {p.name}: {p.value.toFixed(2)}%
))} - - - - {isCopied ? : } - - - {onAddFilter && ( - <> - - - onAddFilter(title, String(label ?? ''), 'include') - } - > - - - - - - onAddFilter(title, String(label ?? ''), 'exclude') - } - > - - - - - )} -
); @@ -468,6 +395,67 @@ const HDXBarChartTooltip = withErrorBoundary( }, ); +// Custom XAxis tick that truncates long labels and adds a native SVG tooltip. +function TruncatedTick({ x, y, payload }: any) { + const value = String(payload?.value ?? ''); + const MAX_CHARS = 12; + const displayValue = + value.length > MAX_CHARS ? value.slice(0, MAX_CHARS) + '…' : value; + return ( + + {value} + + {displayValue} + + + ); +} + +// When a field has more than this many distinct values, the remaining values +// are collapsed into a single "Other (N)" bucket shown in neutral gray. +export const MAX_CHART_VALUES = 6; + +// Aggregates chart data beyond MAX_CHART_VALUES into a single "Other (N)" entry. +// Sorts by combined count (outlier + inlier) descending so the most frequent +// values are kept. Returns data unchanged if already within the limit. +export function applyTopNAggregation( + data: { name: string; outlierCount: number; inlierCount: number }[], +): { + name: string; + outlierCount: number; + inlierCount: number; + isOther?: boolean; +}[] { + if (data.length <= MAX_CHART_VALUES) return data; + + const sorted = [...data].sort( + (a, b) => + b.outlierCount + b.inlierCount - (a.outlierCount + a.inlierCount), + ); + const top = sorted.slice(0, MAX_CHART_VALUES); + const rest = sorted.slice(MAX_CHART_VALUES); + + const otherOutlierCount = rest.reduce((sum, item) => sum + item.outlierCount, 0); + const otherInlierCount = rest.reduce((sum, item) => sum + item.inlierCount, 0); + + return [ + ...top, + { + name: `Other (${rest.length})`, + outlierCount: otherOutlierCount, + inlierCount: otherInlierCount, + isOther: true, + }, + ]; +} + function PropertyComparisonChart({ name, outlierValueOccurences, @@ -483,51 +471,60 @@ function PropertyComparisonChart({ outlierValueOccurences, inlierValueOccurences, ); - - // Controlled tooltip state - keeps tooltip open while hovering over action buttons - const [tooltipState, setTooltipState] = useState<{ - active: boolean; - label: string; - payload: any[]; - }>({ active: false, label: '', payload: [] }); - const tooltipHideTimeoutRef = useRef(undefined); - + const chartData = applyTopNAggregation(mergedValueStatistics); + + const [clickedBar, setClickedBar] = useState<{ + value: string; + clientX: number; + clientY: number; + } | null>(null); + const [copiedValue, setCopiedValue] = useState(false); + const popoverRef = useRef(null); + const chartWrapperRef = useRef(null); + + // Dismiss popover when clicking outside both the popover and the chart wrapper useEffect(() => { - return () => { - if (tooltipHideTimeoutRef.current) { - clearTimeout(tooltipHideTimeoutRef.current); + if (!clickedBar) return; + const handleClickOutside = (e: MouseEvent) => { + if ( + popoverRef.current && + !popoverRef.current.contains(e.target as Node) && + chartWrapperRef.current && + !chartWrapperRef.current.contains(e.target as Node) + ) { + setClickedBar(null); } }; - }, []); + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [clickedBar]); - const keepTooltipOpen = useCallback(() => { - if (tooltipHideTimeoutRef.current) { - clearTimeout(tooltipHideTimeoutRef.current); + // Dismiss popover on scroll (prevents stale popover when chart scrolls offscreen) + useEffect(() => { + if (!clickedBar) return; + const handleScroll = () => setClickedBar(null); + window.addEventListener('scroll', handleScroll, true); + return () => window.removeEventListener('scroll', handleScroll, true); + }, [clickedBar]); + + const handleChartClick = useCallback((data: any, event: any) => { + if (!data?.activePayload?.length) { + setClickedBar(null); + return; } + if (data.activePayload[0]?.payload?.isOther) { + setClickedBar(null); + return; + } + setClickedBar({ + value: String(data.activeLabel ?? ''), + clientX: event.clientX, + clientY: event.clientY, + }); }, []); - const scheduleTooltipClose = useCallback(() => { - tooltipHideTimeoutRef.current = setTimeout(() => { - setTooltipState(prev => ({ ...prev, active: false })); - }, 150); - }, []); - - const handleChartMouseMove = useCallback( - (state: any) => { - if (state.isTooltipActive) { - keepTooltipOpen(); - setTooltipState({ - active: true, - label: state.activeLabel ?? '', - payload: state.activePayload ?? [], - }); - } - }, - [keepTooltipOpen], - ); - return ( -
+
{truncateMiddle(name, 32)} @@ -536,37 +533,22 @@ function PropertyComparisonChart({ barGap={2} width={500} height={300} - data={mergedValueStatistics} + data={chartData} margin={{ top: 0, right: 0, left: 0, bottom: 0, }} - onMouseMove={handleChartMouseMove} - onMouseLeave={scheduleTooltipClose} + onClick={handleChartClick} + style={{ cursor: 'pointer' }} > - {/* */} - + } /> - } + content={} allowEscapeViewBox={{ y: true }} /> + > + {chartData.map((entry, index) => ( + + ))} + + > + {chartData.map((entry, index) => ( + + ))} + + {clickedBar && ( +
+ + {clickedBar.value.length === 0 ? ( + Empty String + ) : ( + clickedBar.value + )} + + + {onAddFilter && ( + <> + + { + onAddFilter(name, clickedBar.value, 'include'); + setClickedBar(null); + }} + > + + + + + { + onAddFilter(name, clickedBar.value, 'exclude'); + setClickedBar(null); + }} + > + + + + + )} + + { + try { + await navigator.clipboard.writeText(clickedBar.value); + setCopiedValue(true); + setTimeout(() => setCopiedValue(false), 2000); + } catch (e) { + console.error('Failed to copy:', e); + } + }} + color={copiedValue ? 'green' : undefined} + > + {copiedValue ? : } + + + +
+ )}
); } diff --git a/packages/app/src/components/__tests__/DBDeltaChart.test.ts b/packages/app/src/components/__tests__/DBDeltaChart.test.ts index b2ee02ec8a..041f24cec9 100644 --- a/packages/app/src/components/__tests__/DBDeltaChart.test.ts +++ b/packages/app/src/components/__tests__/DBDeltaChart.test.ts @@ -1,9 +1,11 @@ import { + applyTopNAggregation, flattenedKeyToSqlExpression, isDenylisted, isHighCardinality, isIdField, isTimestampArrayField, + MAX_CHART_VALUES, } from '../DBDeltaChart'; const traceColumnMeta = [ @@ -329,6 +331,81 @@ describe('isHighCardinality', () => { }); }); +describe('applyTopNAggregation', () => { + const makeData = (names: string[]) => + names.map((name, i) => ({ + name, + outlierCount: 10 - i, // descending counts so order is deterministic + inlierCount: 5, + })); + + it('returns data unchanged when at or below MAX_CHART_VALUES', () => { + const data = makeData(['a', 'b', 'c']); + expect(applyTopNAggregation(data)).toEqual(data); + }); + + it('returns data unchanged when exactly MAX_CHART_VALUES entries', () => { + const data = makeData(['a', 'b', 'c', 'd', 'e', 'f']); + expect(data.length).toBe(MAX_CHART_VALUES); + const result = applyTopNAggregation(data); + expect(result.length).toBe(MAX_CHART_VALUES); + expect(result.some(r => r.isOther)).toBe(false); + }); + + it('collapses values beyond MAX_CHART_VALUES into an Other bucket', () => { + const data = makeData(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']); + const result = applyTopNAggregation(data); + expect(result.length).toBe(MAX_CHART_VALUES + 1); + const other = result[result.length - 1]; + expect(other.isOther).toBe(true); + expect(other.name).toBe('Other (2)'); + }); + + it('keeps top N entries by combined count', () => { + // 8 entries: first 6 have higher combined counts + const data = makeData(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']); + const result = applyTopNAggregation(data); + const topNames = result.slice(0, MAX_CHART_VALUES).map(r => r.name); + expect(topNames).toContain('a'); + expect(topNames).toContain('b'); + expect(topNames).not.toContain('g'); + expect(topNames).not.toContain('h'); + }); + + it('Other bucket accumulates outlierCount and inlierCount from dropped values', () => { + const data = [ + { name: 'a', outlierCount: 50, inlierCount: 50 }, + { name: 'b', outlierCount: 40, inlierCount: 40 }, + { name: 'c', outlierCount: 30, inlierCount: 30 }, + { name: 'd', outlierCount: 20, inlierCount: 20 }, + { name: 'e', outlierCount: 10, inlierCount: 10 }, + { name: 'f', outlierCount: 5, inlierCount: 5 }, + { name: 'g', outlierCount: 3, inlierCount: 2 }, // dropped + { name: 'h', outlierCount: 1, inlierCount: 1 }, // dropped + ]; + const result = applyTopNAggregation(data); + const other = result.find(r => r.isOther); + expect(other).toBeDefined(); + expect(other!.outlierCount).toBe(4); // 3 + 1 + expect(other!.inlierCount).toBe(3); // 2 + 1 + }); + + it('Other bucket name shows the count of aggregated values', () => { + const data = makeData(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']); + const result = applyTopNAggregation(data); + const other = result.find(r => r.isOther); + expect(other?.name).toBe('Other (3)'); + }); + + it('non-Other entries do not have isOther set', () => { + const data = makeData(['a', 'b', 'c', 'd', 'e', 'f', 'g']); + const result = applyTopNAggregation(data); + result.slice(0, MAX_CHART_VALUES).forEach(entry => { + expect(entry.isOther).toBeFalsy(); + }); + }); +}); + describe('field split logic (visible vs hidden)', () => { it('correctly classifies a mix of ID, timestamp, cardinality, and useful fields', () => { // TraceId → denylist (ID field, String) From 0d52f016f45f5660ae43d30bef0780c76e6b8327 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:27:51 +0000 Subject: [PATCH 06/43] fix: resolve popup z-index, light theme, missing title, and selection persistence bugs - Bug 1 (z-index): Render click popover via createPortal into document.body so it always appears above all chart siblings in the grid layout - Bug 2 (light theme): Replace hardcoded dark-mode CSS variables with theme-aware chartTooltip CSS class (uses --color-bg-surface / --color-border) - Bug 3 (missing title): Add property name header and outlier/inlier counts with percentages to the click popover content - Bug 4 (selection persistence): Pass initialSelection prop from DBSearchHeatmapChart through DBHeatmapChart (HeatmapContainer) to Heatmap; capture uPlot instance via plugin init hook and restore the selection overlay with correct pixel coordinates after the component remounts on filter change Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBDeltaChart.tsx | 227 +++++++++++------- .../app/src/components/DBHeatmapChart.tsx | 60 ++++- .../Search/DBSearchHeatmapChart.tsx | 13 + 3 files changed, 212 insertions(+), 88 deletions(-) diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index 9397075e1c..7edc287a75 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -1,3 +1,4 @@ +import { createPortal } from 'react-dom'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { withErrorBoundary } from 'react-error-boundary'; import { @@ -473,6 +474,17 @@ function PropertyComparisonChart({ ); const chartData = applyTopNAggregation(mergedValueStatistics); + const totalOutliers = useMemo( + () => + Array.from(outlierValueOccurences.values()).reduce((a, b) => a + b, 0), + [outlierValueOccurences], + ); + const totalInliers = useMemo( + () => + Array.from(inlierValueOccurences.values()).reduce((a, b) => a + b, 0), + [inlierValueOccurences], + ); + const [clickedBar, setClickedBar] = useState<{ value: string; clientX: number; @@ -579,96 +591,137 @@ function PropertyComparisonChart({ - {clickedBar && ( -
- - {clickedBar.value.length === 0 ? ( - Empty String - ) : ( - clickedBar.value - )} - - - {onAddFilter && ( - <> - - { - onAddFilter(name, clickedBar.value, 'include'); - setClickedBar(null); - }} + {clickedBar && + createPortal( +
+ + {truncateMiddle(name, 40)} + + + {clickedBar.value.length === 0 ? ( + Empty String + ) : ( + clickedBar.value + )} + + {(() => { + const outlierCount = + outlierValueOccurences.get(clickedBar.value) ?? 0; + const inlierCount = + inlierValueOccurences.get(clickedBar.value) ?? 0; + const outlierPct = + totalOutliers > 0 + ? ((outlierCount / totalOutliers) * 100).toFixed(1) + : null; + const inlierPct = + totalInliers > 0 + ? ((inlierCount / totalInliers) * 100).toFixed(1) + : null; + return ( + + + Outliers: {outlierCount} + {outlierPct != null ? ` (${outlierPct}%)` : ''} + + + Inliers: {inlierCount} + {inlierPct != null ? ` (${inlierPct}%)` : ''} + + + ); + })()} + + {onAddFilter && ( + <> + - - - - - { - onAddFilter(name, clickedBar.value, 'exclude'); - setClickedBar(null); - }} + { + onAddFilter(name, clickedBar.value, 'include'); + setClickedBar(null); + }} + > + + + + - - - - - )} - - { - try { - await navigator.clipboard.writeText(clickedBar.value); - setCopiedValue(true); - setTimeout(() => setCopiedValue(false), 2000); - } catch (e) { - console.error('Failed to copy:', e); - } - }} - color={copiedValue ? 'green' : undefined} + { + onAddFilter(name, clickedBar.value, 'exclude'); + setClickedBar(null); + }} + > + + + + + )} + - {copiedValue ? : } - - - -
- )} + { + try { + await navigator.clipboard.writeText(clickedBar.value); + setCopiedValue(true); + setTimeout(() => setCopiedValue(false), 2000); + } catch (e) { + console.error('Failed to copy:', e); + } + }} + color={copiedValue ? 'green' : undefined} + > + {copiedValue ? ( + + ) : ( + + )} + +
+
+
, + document.body, + )}
); } diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index 07888f35ac..2f72578659 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, 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'; @@ -287,6 +287,7 @@ function HeatmapContainer({ title, toolbarPrefix, toolbarSuffix, + initialSelection, }: { config: HeatmapChartConfig; enabled?: boolean; @@ -294,6 +295,12 @@ function HeatmapContainer({ title?: React.ReactNode; toolbarPrefix?: React.ReactNode[]; toolbarSuffix?: React.ReactNode[]; + initialSelection?: { + xMin: number; + xMax: number; + yMin: number; + yMax: number; + } | null; }) { const dateRange = config.dateRange; const granularity = convertDateRangeToGranularityString(dateRange, 245); @@ -555,6 +562,7 @@ function HeatmapContainer({ data={[time, bucket, count]} numberFormat={config.numberFormat} onFilter={onFilter} + initialSelection={initialSelection} /> )} @@ -666,11 +674,22 @@ function Heatmap({ data, numberFormat, onFilter, + initialSelection, }: { data: Mode2DataArray; numberFormat?: NumberFormat; onFilter?: (xMin: number, xMax: number, yMin: number, yMax: number) => void; + initialSelection?: { + xMin: number; + xMax: number; + yMin: number; + yMax: number; + } | null; }) { + const uplotRef = useRef(null); + // Incremented each time uPlot initializes, used to trigger the selection-restore effect + const [uplotInitCount, setUplotInitCount] = useState(0); + const [selectingInfo, setSelectingInfo] = useState< | { // In pixel units @@ -784,6 +803,10 @@ function Heatmap({ }), { hooks: { + init: u => { + uplotRef.current = u; + setUplotInitCount(c => c + 1); + }, setSelect: u => { // Calculate offset from parent so we can render tooltip // relative to the parent pixels @@ -815,6 +838,41 @@ function Heatmap({ }; }, [width, height, tickFormatter]); + // Restore the persisted selection after uPlot initializes (e.g., after + // the component remounts due to config change). Converts data-unit coordinates + // (xMin/xMax in seconds, yMin/yMax in value units) back to pixel positions. + useEffect(() => { + const u = uplotRef.current; + if (!u || !initialSelection) return; + const { offsetLeft, offsetTop } = u.over; + // xMin/xMax are stored in seconds; uPlot x-axis is in milliseconds + const left = u.valToPos(initialSelection.xMin * 1000, 'x'); + const right = u.valToPos(initialSelection.xMax * 1000, 'x'); + // y-axis: higher value → smaller pixel position (inverted) + const top = u.valToPos(initialSelection.yMax, 'y'); + const bottom = u.valToPos(initialSelection.yMin, 'y'); + if ( + !isFinite(left) || + !isFinite(right) || + !isFinite(top) || + !isFinite(bottom) || + right <= left || + bottom <= top + ) + return; + setSelectingInfo({ + left: left + offsetLeft, + top: top + offsetTop, + width: right - left, + height: bottom - top, + xMin: initialSelection.xMin * 1000, + xMax: initialSelection.xMax * 1000, + yMin: initialSelection.yMin, + yMax: initialSelection.yMax, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [uplotInitCount]); + return (
{fields.xMin != null && From 367c16e478ce999bbd9a4770a5f2d73ec0000441 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:49:50 +0000 Subject: [PATCH 07/43] fix: resolve popup tooltip z-index, chart selection persistence, and pagination for lower-priority fields - Bug 1: Add zIndex=10000 to MantineTooltip components inside click popover so they render above the portal-rendered popover instead of behind/below it - Bug 2: Chart selection restore effect now also depends on data[0].length so it re-runs when heatmap data actually loads (uPlot init fires before data is ready, making valToPos return NaN; adding dataLength dependency ensures restore runs with valid scales) - Bug 3: Lower-priority fields are now included in pagination (totalProperties = visible + hidden). Two separate grids with a divider handle pages that contain both sections, avoiding layout issues where the divider appears mid-row Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBDeltaChart.tsx | 124 +++++++++++------- .../app/src/components/DBHeatmapChart.tsx | 12 +- 2 files changed, 81 insertions(+), 55 deletions(-) diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index 7edc287a75..980002ce12 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -660,6 +660,7 @@ function PropertyComparisonChart({ position="top" withArrow fz="xs" + zIndex={10000} > 0 && - (totalPages === 0 || activePage === totalPages); + const pageStart = (activePage - 1) * PAGE_SIZE; + const pageEnd = activePage * PAGE_SIZE; + + // Primary (visible) fields on the current page + const visibleOnPage = visibleProperties.slice( + Math.max(0, pageStart), + Math.min(visibleProperties.length, pageEnd), + ); + + // Lower-priority (hidden) fields on the current page + const hiddenPageStart = Math.max(0, pageStart - visibleProperties.length); + const hiddenPageEnd = Math.max(0, pageEnd - visibleProperties.length); + const hiddenOnPage = hiddenProperties.slice(hiddenPageStart, hiddenPageEnd); + + // Show a divider when both sections appear on the same page + const showDivider = visibleOnPage.length > 0 && hiddenOnPage.length > 0; + // Show a header when ONLY hidden fields appear on this page (no divider above) + const showHiddenHeader = hiddenOnPage.length > 0 && visibleOnPage.length === 0; return ( -
- {visibleProperties - .slice((activePage - 1) * PAGE_SIZE, activePage * PAGE_SIZE) - .map(property => ( + {/* Primary fields — own grid so empty trailing cells don't interact with divider */} + {visibleOnPage.length > 0 && ( +
+ {visibleOnPage.map(property => ( ))} -
- {showLowerPriorityFields && ( - <> - - Lower-priority fields ({hiddenProperties.length}) - - } - labelPosition="left" - /> -
- {hiddenProperties.map(key => ( - - ))} -
- +
+ )} + {/* Divider between primary and lower-priority fields */} + {(showDivider || showHiddenHeader) && ( + + Lower-priority fields ({hiddenProperties.length}) + + } + labelPosition="left" + /> + )} + {/* Lower-priority fields — separate grid so rows align independently */} + {hiddenOnPage.length > 0 && ( +
+ {hiddenOnPage.map(key => ( + + ))} +
)} { const u = uplotRef.current; - if (!u || !initialSelection) return; + if (!u || !initialSelection || dataLength < 2) return; const { offsetLeft, offsetTop } = u.over; // xMin/xMax are stored in seconds; uPlot x-axis is in milliseconds const left = u.valToPos(initialSelection.xMin * 1000, 'x'); @@ -871,7 +873,7 @@ function Heatmap({ yMax: initialSelection.yMax, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [uplotInitCount]); + }, [uplotInitCount, dataLength]); return (
Date: Wed, 25 Feb 2026 22:56:07 +0000 Subject: [PATCH 08/43] fix: resolve hover tooltip z-index and filter button styling in Event Deltas - Add wrapperStyle={{ zIndex: 9998 }} to Recharts Tooltip so the hover tooltip renders above sibling chart cards in the grid - Replace ActionIcon variant='primary'/'secondary' with DBRowTableIconButton variant='copy' for Filter, Exclude, Copy actions in the click popover, matching the consistent styling used in the results table and trace flyout - Remove unused ActionIcon, MantineTooltip, IconCheck imports Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBDeltaChart.tsx | 101 +++++++------------ 1 file changed, 36 insertions(+), 65 deletions(-) diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index 980002ce12..c05c3741e2 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -17,7 +17,6 @@ import { Filter, } from '@hyperdx/common-utils/dist/types'; import { - ActionIcon, Box, Code, Container, @@ -25,11 +24,9 @@ import { Flex, Pagination, Text, - Tooltip as MantineTooltip, } from '@mantine/core'; import { useElementSize } from '@mantine/hooks'; import { - IconCheck, IconCopy, IconFilter, IconFilterX, @@ -45,6 +42,7 @@ import { } from '@/utils'; import { SQLPreview } from './ChartSQLPreview'; +import { DBRowTableIconButton } from './DBTable/DBRowTableIconButton'; import styles from '../../styles/HDXLineChart.module.scss'; @@ -561,7 +559,8 @@ function PropertyComparisonChart({ /> } - allowEscapeViewBox={{ y: true }} + allowEscapeViewBox={{ x: true, y: true }} + wrapperStyle={{ zIndex: 9998 }} /> {onAddFilter && ( <> - { + onAddFilter(name, clickedBar.value, 'include'); + setClickedBar(null); + }} > - { - onAddFilter(name, clickedBar.value, 'include'); - setClickedBar(null); - }} - > - - - - + + { + onAddFilter(name, clickedBar.value, 'exclude'); + setClickedBar(null); + }} > - { - onAddFilter(name, clickedBar.value, 'exclude'); - setClickedBar(null); - }} - > - - - + + )} - { + try { + await navigator.clipboard.writeText(clickedBar.value); + setCopiedValue(true); + setTimeout(() => setCopiedValue(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }} > - { - try { - await navigator.clipboard.writeText(clickedBar.value); - setCopiedValue(true); - setTimeout(() => setCopiedValue(false), 2000); - } catch (e) { - console.error('Failed to copy:', e); - } - }} - color={copiedValue ? 'green' : undefined} - > - {copiedValue ? ( - - ) : ( - - )} - - + +
, document.body, From e2e3e67a6aec71e8443b1897310efeaddcc0c470 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:33:15 +0000 Subject: [PATCH 09/43] feat: always show distribution chart, add legend, clear selection on filter - DBDeltaChart: make xMin/xMax/yMin/yMax optional; add allSpans query when no selection so charts show value distributions even without a heatmap selection - PropertyComparisonChart: add hasSelection prop; single blue bar (All spans) when no selection, two-bar comparison (Selection/Background) when selection exists; update click popover labels - Add legend at top of chart grid: swatches for Selection/Background when comparison active; All spans swatch + hint to select area when no selection - DBSearchHeatmapChart: always render DBDeltaChart; wrap onAddFilter to clear selection coords from URL after filtering so chart resets to all-spans mode - DBHeatmapChart: remove broken selection-restoration logic (uplotRef, uplotInitCount, restore useEffect) and initialSelection prop entirely Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBDeltaChart.tsx | 248 +++++++++++++----- .../app/src/components/DBHeatmapChart.tsx | 62 +---- .../Search/DBSearchHeatmapChart.tsx | 74 ++---- 3 files changed, 207 insertions(+), 177 deletions(-) diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index c05c3741e2..a6c3de8664 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -421,6 +421,9 @@ function TruncatedTick({ x, y, payload }: any) { // are collapsed into a single "Other (N)" bucket shown in neutral gray. export const MAX_CHART_VALUES = 6; +// Color for the "All spans" distribution bar (no selection / comparison mode off) +const ALL_SPANS_COLOR = '#339af0'; + // Aggregates chart data beyond MAX_CHART_VALUES into a single "Other (N)" entry. // Sorts by combined count (outlier + inlier) descending so the most frequent // values are kept. Returns data unchanged if already within the limit. @@ -460,11 +463,13 @@ function PropertyComparisonChart({ outlierValueOccurences, inlierValueOccurences, onAddFilter, + hasSelection, }: { name: string; outlierValueOccurences: Map; inlierValueOccurences: Map; onAddFilter?: AddFilterFn; + hasSelection: boolean; }) { const mergedValueStatistics = mergeValueStatisticsMaps( outlierValueOccurences, @@ -564,30 +569,38 @@ function PropertyComparisonChart({ /> {chartData.map((entry, index) => ( - ))} - - - {chartData.map((entry, index) => ( - ))} + {hasSelection && ( + + {chartData.map((entry, index) => ( + + ))} + + )} {clickedBar && @@ -625,32 +638,30 @@ function PropertyComparisonChart({ clickedBar.value )} - {(() => { - const outlierCount = - outlierValueOccurences.get(clickedBar.value) ?? 0; - const inlierCount = - inlierValueOccurences.get(clickedBar.value) ?? 0; - const outlierPct = - totalOutliers > 0 - ? ((outlierCount / totalOutliers) * 100).toFixed(1) - : null; - const inlierPct = - totalInliers > 0 - ? ((inlierCount / totalInliers) * 100).toFixed(1) - : null; - return ( - - - Outliers: {outlierCount} - {outlierPct != null ? ` (${outlierPct}%)` : ''} - - - Inliers: {inlierCount} - {inlierPct != null ? ` (${inlierPct}%)` : ''} - - - ); - })()} + {hasSelection ? ( + + + Selection:{' '} + {(outlierValueOccurences.get(clickedBar.value) ?? 0).toFixed( + 1, + )} + % + + + Background:{' '} + {(inlierValueOccurences.get(clickedBar.value) ?? 0).toFixed( + 1, + )} + % + + + ) : ( + + Distribution:{' '} + {(outlierValueOccurences.get(clickedBar.value) ?? 0).toFixed(1)} + % + + )} {onAddFilter && ( <> @@ -715,20 +726,29 @@ const PAGINATION_HEIGHT = 48; export default function DBDeltaChart({ config, valueExpr, - xMin, - xMax, - yMin, - yMax, + xMin: rawXMin, + xMax: rawXMax, + yMin: rawYMin, + yMax: rawYMax, onAddFilter, }: { config: ChartConfigWithDateRange; valueExpr: string; - xMin: number; - xMax: number; - yMin: number; - yMax: number; + xMin?: number | null; + xMax?: number | null; + yMin?: number | null; + yMax?: number | null; onAddFilter?: AddFilterFn; }) { + const hasSelection = + rawXMin != null && rawXMax != null && rawYMin != null && rawYMax != null; + // Safe numeric defaults so query builders always get valid values + // (outlier/inlier queries are gated by enabled:hasSelection) + const xMin = rawXMin ?? 0; + const xMax = rawXMax ?? 0; + const yMin = rawYMin ?? 0; + const yMax = rawYMax ?? 0; + // Determine if the value expression uses aggregate functions const isAggregate = isAggregateFunction(valueExpr); @@ -865,23 +885,42 @@ export default function DBDeltaChart({ ]; }; - const { data: outlierData, error } = useQueriedChartConfig({ - ...config, - with: buildWithClauses(true), - select: '*', - filters: buildFilters(true), - orderBy: [{ ordering: 'DESC', valueExpression: 'rand()' }], - limit: { limit: 1000 }, - }); + const { data: outlierData, error: outlierError } = useQueriedChartConfig( + { + ...config, + with: buildWithClauses(true), + select: '*', + filters: buildFilters(true), + orderBy: [{ ordering: 'DESC', valueExpression: 'rand()' }], + limit: { limit: 1000 }, + }, + { enabled: hasSelection }, + ); - const { data: inlierData } = useQueriedChartConfig({ - ...config, - with: buildWithClauses(false), - select: '*', - filters: buildFilters(false), - orderBy: [{ ordering: 'DESC', valueExpression: 'rand()' }], - limit: { limit: 1000 }, - }); + const { data: inlierData } = useQueriedChartConfig( + { + ...config, + with: buildWithClauses(false), + select: '*', + filters: buildFilters(false), + orderBy: [{ ordering: 'DESC', valueExpression: 'rand()' }], + limit: { limit: 1000 }, + }, + { enabled: hasSelection }, + ); + + // When no selection exists, fetch all spans without any range filter + const { data: allSpansData, error: allSpansError } = useQueriedChartConfig( + { + ...config, + select: '*', + orderBy: [{ ordering: 'DESC', valueExpression: 'rand()' }], + limit: { limit: 1000 }, + }, + { enabled: !hasSelection }, + ); + + const error = outlierError ?? allSpansError; // Compute column metadata, property statistics, sorted/visible/hidden property lists. // columnMeta is merged here (instead of a separate useMemo) so the denylist and @@ -893,20 +932,32 @@ export default function DBDeltaChart({ visibleProperties, hiddenProperties, } = useMemo(() => { - const columnMeta = (outlierData?.meta ?? inlierData?.meta ?? []) as { + const columnMeta = ( + outlierData?.meta ?? + inlierData?.meta ?? + allSpansData?.meta ?? + [] + ) as { name: string; type: string; }[]; + // When no selection: use allSpans as "outlier" data and empty for inliers. + // The sort will rank by frequency (delta = count - 0 = count). + const actualOutlierData = hasSelection + ? (outlierData?.data ?? []) + : (allSpansData?.data ?? []); + const actualInlierData = hasSelection ? (inlierData?.data ?? []) : []; + const { percentageOccurences: outlierValueOccurences, propertyOccurences: outlierPropertyOccurences, - } = getPropertyStatistics(outlierData?.data ?? []); + } = getPropertyStatistics(actualOutlierData); const { percentageOccurences: inlierValueOccurences, propertyOccurences: inlierPropertyOccurences, - } = getPropertyStatistics(inlierData?.data ?? []); + } = getPropertyStatistics(actualInlierData); // Get all the unique keys from the outliers let uniqueKeys = new Set([...outlierValueOccurences.keys()]); @@ -968,7 +1019,7 @@ export default function DBDeltaChart({ visibleProperties, hiddenProperties, }; - }, [outlierData, inlierData]); + }, [outlierData, inlierData, allSpansData, hasSelection]); // Wrap onAddFilter to convert flattened dot-notation keys (from flattenData) // into valid ClickHouse SQL expressions before passing to the filter handler. @@ -1081,6 +1132,61 @@ export default function DBDeltaChart({ flexDirection: 'column', }} > + {/* Legend */} + + {hasSelection ? ( + <> + + + + Selection + + + + + + Background + + + + ) : ( + <> + + + + All spans + + + + Select an area on the chart above to enable comparisons + + + )} + {/* Primary fields — own grid so empty trailing cells don't interact with divider */} {visibleOnPage.length > 0 && (
))} @@ -1137,6 +1244,7 @@ export default function DBDeltaChart({ inlierValueOccurences.get(key) ?? new Map() } onAddFilter={onAddFilter ? handleAddFilter : undefined} + hasSelection={hasSelection} key={key} /> ))} diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index e1ed374bed..07888f35ac 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import dynamic from 'next/dynamic'; import type { Plugin } from 'uplot'; import uPlot from 'uplot'; @@ -287,7 +287,6 @@ function HeatmapContainer({ title, toolbarPrefix, toolbarSuffix, - initialSelection, }: { config: HeatmapChartConfig; enabled?: boolean; @@ -295,12 +294,6 @@ function HeatmapContainer({ title?: React.ReactNode; toolbarPrefix?: React.ReactNode[]; toolbarSuffix?: React.ReactNode[]; - initialSelection?: { - xMin: number; - xMax: number; - yMin: number; - yMax: number; - } | null; }) { const dateRange = config.dateRange; const granularity = convertDateRangeToGranularityString(dateRange, 245); @@ -562,7 +555,6 @@ function HeatmapContainer({ data={[time, bucket, count]} numberFormat={config.numberFormat} onFilter={onFilter} - initialSelection={initialSelection} /> )} @@ -674,22 +666,11 @@ function Heatmap({ data, numberFormat, onFilter, - initialSelection, }: { data: Mode2DataArray; numberFormat?: NumberFormat; onFilter?: (xMin: number, xMax: number, yMin: number, yMax: number) => void; - initialSelection?: { - xMin: number; - xMax: number; - yMin: number; - yMax: number; - } | null; }) { - const uplotRef = useRef(null); - // Incremented each time uPlot initializes, used to trigger the selection-restore effect - const [uplotInitCount, setUplotInitCount] = useState(0); - const [selectingInfo, setSelectingInfo] = useState< | { // In pixel units @@ -803,10 +784,6 @@ function Heatmap({ }), { hooks: { - init: u => { - uplotRef.current = u; - setUplotInitCount(c => c + 1); - }, setSelect: u => { // Calculate offset from parent so we can render tooltip // relative to the parent pixels @@ -838,43 +815,6 @@ function Heatmap({ }; }, [width, height, tickFormatter]); - // Restore the persisted selection after uPlot initializes AND data has loaded. - // The uPlot `init` hook fires before data is available so scales aren't set yet — - // pixel conversion would return NaN/Infinity. We also depend on `data[0].length` - // so the effect re-runs once the heatmap data actually arrives. - const dataLength = data[0].length; - useEffect(() => { - const u = uplotRef.current; - if (!u || !initialSelection || dataLength < 2) return; - const { offsetLeft, offsetTop } = u.over; - // xMin/xMax are stored in seconds; uPlot x-axis is in milliseconds - const left = u.valToPos(initialSelection.xMin * 1000, 'x'); - const right = u.valToPos(initialSelection.xMax * 1000, 'x'); - // y-axis: higher value → smaller pixel position (inverted) - const top = u.valToPos(initialSelection.yMax, 'y'); - const bottom = u.valToPos(initialSelection.yMin, 'y'); - if ( - !isFinite(left) || - !isFinite(right) || - !isFinite(top) || - !isFinite(bottom) || - right <= left || - bottom <= top - ) - return; - setSelectingInfo({ - left: left + offsetLeft, - top: top + offsetTop, - width: right - left, - height: bottom - top, - xMin: initialSelection.xMin * 1000, - xMax: initialSelection.xMax * 1000, - yMin: initialSelection.yMin, - yMax: initialSelection.yMax, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [uplotInitCount, dataLength]); - return (
(null); + // After applying a filter, clear the heatmap selection so the delta chart + // resets to "all spans" distribution mode instead of staying in comparison mode. + const handleAddFilterAndClearSelection = useCallback< + NonNullable + >( + (property, value, action) => { + setFields({ xMin: null, xMax: null, yMin: null, yMax: null }); + onAddFilter?.(property, value, action); + }, + [onAddFilter, setFields], + ); + return ( { - // Simply store the coordinates - DBDeltaChart will handle the logic setFields({ xMin, xMax, @@ -106,47 +113,22 @@ export function DBSearchHeatmapChart({ yMax, }); }} - initialSelection={ - fields.xMin != null && - fields.xMax != null && - fields.yMin != null && - fields.yMax != null - ? { - xMin: fields.xMin, - xMax: fields.xMax, - yMin: fields.yMin, - yMax: fields.yMax, - } - : null - } />
- {fields.xMin != null && - fields.xMax != null && - fields.yMin != null && - fields.yMax != null ? ( - - ) : ( - -
- - Please highlight an outlier range in the heatmap to view the delta - chart. - -
-
- )} + ); } From c207135954cfff2e2ff3cc81973acf326f0f009c Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:20:48 +0000 Subject: [PATCH 10/43] feat: improve sort order, add reset button, and add heatmap correlation highlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix sort order in distribution mode: use skewness score (max(pct) - 100/n) instead of max(pct) alone — single-value fields now score 0 and are ranked below genuinely skewed multi-value distributions - Add reset button (×) in the legend to clear the heatmap selection - Correlation highlight: hovering an attribute value bar highlights the matching spans' time positions on the heatmap as white vertical lines, drawn directly on the uPlot canvas via a draw hook - Export computeDistributionScore helper and add unit tests for it Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBDeltaChart.tsx | 146 ++++++++++++++++-- .../app/src/components/DBHeatmapChart.tsx | 61 +++++++- .../Search/DBSearchHeatmapChart.tsx | 13 ++ .../components/__tests__/DBDeltaChart.test.ts | 63 ++++++++ 4 files changed, 269 insertions(+), 14 deletions(-) diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index a6c3de8664..4e904c43aa 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -17,6 +17,7 @@ import { Filter, } from '@hyperdx/common-utils/dist/types'; import { + ActionIcon, Box, Code, Container, @@ -30,6 +31,7 @@ import { IconCopy, IconFilter, IconFilterX, + IconX, } from '@tabler/icons-react'; import { isAggregateFunction } from '@/ChartUtils'; @@ -347,6 +349,29 @@ function mergeValueStatisticsMaps( return mergedArray; } +/** + * Computes a distribution skewness score for sorting properties in "all spans" mode. + * + * Score = max(pct) - (100 / n_values): + * - 0 for single-value fields (all spans share the same value → not useful for filtering) + * - 0 for perfectly uniform multi-value fields + * - High for skewed distributions where one value dominates others + * + * @param valuePercentages - Map from value string to its percentage (0–100) of occurrences + */ +export function computeDistributionScore( + valuePercentages: Map, +): number { + const nValues = valuePercentages.size; + if (nValues <= 1) return 0; + const uniformExpected = 100 / nValues; + let maxPct = 0; + valuePercentages.forEach(pct => { + if (pct > maxPct) maxPct = pct; + }); + return maxPct - uniformExpected; +} + export type AddFilterFn = ( property: string, value: string, @@ -464,12 +489,14 @@ function PropertyComparisonChart({ inlierValueOccurences, onAddFilter, hasSelection, + onHoverValue, }: { name: string; outlierValueOccurences: Map; inlierValueOccurences: Map; onAddFilter?: AddFilterFn; hasSelection: boolean; + onHoverValue?: (property: string, value: string | null) => void; }) { const mergedValueStatistics = mergeValueStatisticsMaps( outlierValueOccurences, @@ -496,6 +523,8 @@ function PropertyComparisonChart({ const [copiedValue, setCopiedValue] = useState(false); const popoverRef = useRef(null); const chartWrapperRef = useRef(null); + // Track last hovered bar value to avoid firing onHoverValue on every pixel move + const lastHoveredBarRef = useRef(null); // Dismiss popover when clicking outside both the popover and the chart wrapper useEffect(() => { @@ -557,6 +586,22 @@ function PropertyComparisonChart({ }} onClick={handleChartClick} style={{ cursor: 'pointer' }} + onMouseMove={(data: any) => { + const label = data?.activeLabel; + const isOther = data?.activePayload?.[0]?.payload?.isOther; + const newVal = + label != null && !isOther ? String(label) : null; + if (newVal !== lastHoveredBarRef.current) { + lastHoveredBarRef.current = newVal; + onHoverValue?.(name, newVal); + } + }} + onMouseLeave={() => { + if (lastHoveredBarRef.current !== null) { + lastHoveredBarRef.current = null; + onHoverValue?.(name, null); + } + }} > } /> void; + onHighlightTimestamps?: (timestamps: number[] | null) => void; }) { const hasSelection = rawXMin != null && rawXMax != null && rawYMin != null && rawYMax != null; @@ -965,7 +1014,12 @@ export default function DBDeltaChart({ if (uniqueKeys.size === 0) { uniqueKeys = new Set([...inlierValueOccurences.keys()]); } - // Now process the keys to find the ones with the highest delta between outlier and inlier percentages + // Sort properties by how useful they are for filtering/analysis. + // Comparison mode: sort by max difference between selection and background. + // Distribution mode (no selection): sort by deviation from uniform distribution — + // score = max(pct) - (100 / n_values) + // This scores 0 for single-value fields (all-same) and for perfectly uniform + // multi-value fields, and scores high for skewed distributions. const sortedProperties = Array.from(uniqueKeys) .map(key => { const inlierCount = @@ -973,19 +1027,21 @@ export default function DBDeltaChart({ const outlierCount = outlierValueOccurences.get(key) ?? new Map(); - const mergedArray = mergeValueStatisticsMaps( - outlierCount, - inlierCount, - ); - let maxValueDelta = 0; - mergedArray.forEach(item => { - const delta = Math.abs(item.outlierCount - item.inlierCount); - if (delta > maxValueDelta) { - maxValueDelta = delta; - } - }); + let sortScore: number; + if (hasSelection) { + const mergedArray = mergeValueStatisticsMaps(outlierCount, inlierCount); + let maxValueDelta = 0; + mergedArray.forEach(item => { + const delta = Math.abs(item.outlierCount - item.inlierCount); + if (delta > maxValueDelta) maxValueDelta = delta; + }); + sortScore = maxValueDelta; + } else { + // Distribution mode: sort by deviation from uniform distribution. + sortScore = computeDistributionScore(outlierCount); + } - return [key, maxValueDelta] as const; + return [key, sortScore] as const; }) .sort((a, b) => b[1] - a[1]) .map(a => a[0]); @@ -1012,12 +1068,16 @@ export default function DBDeltaChart({ } }); + // Precompute flattened raw data for hover-based timestamp lookup + const flattenedRawData = actualOutlierData.map(flattenData); + return { outlierValueOccurences, inlierValueOccurences, columnMeta, visibleProperties, hiddenProperties, + flattenedRawData, }; }, [outlierData, inlierData, allSpansData, hasSelection]); @@ -1035,6 +1095,52 @@ export default function DBDeltaChart({ [onAddFilter, columnMeta], ); + // Track hovered attribute value for correlation highlighting on the heatmap + const [hoveredAttributeValue, setHoveredAttributeValue] = useState<{ + property: string; + value: string; + } | null>(null); + + const handleHoverValue = useCallback( + (property: string, value: string | null) => { + setHoveredAttributeValue(value != null ? { property, value } : null); + }, + [], + ); + + // Compute timestamps of spans matching the hovered attribute value. + // Used to highlight corresponding time positions on the heatmap. + const highlightTimestampMs = useMemo(() => { + if (!hoveredAttributeValue) return null; + const { property, value } = hoveredAttributeValue; + + // Find the first non-array DateTime64 column (typically 'Timestamp') + const tsColName = columnMeta.find( + c => + (stripTypeWrappers(c.type).startsWith('DateTime64(') || + c.type === 'DateTime64') && + !stripTypeWrappers(c.type).startsWith('Array('), + )?.name; + if (!tsColName) return null; + + const timestamps: number[] = []; + for (const flat of flattenedRawData) { + if (String(flat[property]) === value) { + const ts = flat[tsColName]; + if (ts != null) { + const tsMs = new Date(ts as string).getTime(); + if (!isNaN(tsMs)) timestamps.push(tsMs); + } + } + } + return timestamps.length > 0 ? timestamps : null; + }, [hoveredAttributeValue, flattenedRawData, columnMeta]); + + // Propagate highlight timestamps to parent (e.g., DBSearchHeatmapChart) + useEffect(() => { + onHighlightTimestamps?.(highlightTimestampMs); + }, [highlightTimestampMs, onHighlightTimestamps]); + const [activePage, setPage] = useState(1); const { @@ -1164,6 +1270,18 @@ export default function DBDeltaChart({ Background + {onClearSelection && ( + + + + )} ) : ( <> @@ -1207,6 +1325,7 @@ export default function DBDeltaChart({ } onAddFilter={onAddFilter ? handleAddFilter : undefined} hasSelection={hasSelection} + onHoverValue={onHighlightTimestamps ? handleHoverValue : undefined} key={property} /> ))} @@ -1245,6 +1364,7 @@ export default function DBDeltaChart({ } onAddFilter={onAddFilter ? handleAddFilter : undefined} hasSelection={hasSelection} + onHoverValue={onHighlightTimestamps ? handleHoverValue : undefined} key={key} /> ))} diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index 07888f35ac..f4dd5dc268 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, 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'; @@ -287,6 +287,7 @@ function HeatmapContainer({ title, toolbarPrefix, toolbarSuffix, + highlightTimestampMs, }: { config: HeatmapChartConfig; enabled?: boolean; @@ -294,6 +295,7 @@ function HeatmapContainer({ title?: React.ReactNode; toolbarPrefix?: React.ReactNode[]; toolbarSuffix?: React.ReactNode[]; + highlightTimestampMs?: number[] | null; }) { const dateRange = config.dateRange; const granularity = convertDateRangeToGranularityString(dateRange, 245); @@ -555,6 +557,7 @@ function HeatmapContainer({ data={[time, bucket, count]} numberFormat={config.numberFormat} onFilter={onFilter} + highlightTimestampMs={highlightTimestampMs} /> )} @@ -666,10 +669,12 @@ function Heatmap({ data, numberFormat, onFilter, + highlightTimestampMs, }: { data: Mode2DataArray; numberFormat?: NumberFormat; onFilter?: (xMin: number, xMax: number, yMin: number, yMax: number) => void; + highlightTimestampMs?: number[] | null; }) { const [selectingInfo, setSelectingInfo] = useState< | { @@ -687,6 +692,19 @@ function Heatmap({ | undefined >(undefined); + // Refs for correlation highlight overlay: uPlot instance + latest highlight timestamps + const uplotRef = useRef(null); + const highlightTimestampMsRef = useRef(null); + // Keep ref in sync with latest prop value on every render + highlightTimestampMsRef.current = highlightTimestampMs ?? null; + + // Trigger a uPlot redraw when highlight timestamps change so the draw hook re-runs + useEffect(() => { + if (uplotRef.current) { + uplotRef.current.redraw(false); + } + }, [highlightTimestampMs]); + const [highlightedPoint, setHighlightedPoint] = useState< | { xVal: number; @@ -811,8 +829,49 @@ function Heatmap({ }, }, }, + { + // Correlation highlight: draws vertical lines on the heatmap canvas + // at the timestamps of spans matching the hovered attribute value. + hooks: { + init: (u: uPlot) => { + // eslint-disable-next-line react-hooks/exhaustive-deps + uplotRef.current = u; + }, + draw: (u: uPlot) => { + const tss = highlightTimestampMsRef.current; + if (!tss?.length) return; + + u.ctx.save(); + // Clip to the plot area + u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + u.ctx.clip(); + + u.ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)'; + u.ctx.lineWidth = 1.5; + + // Deduplicate pixel positions to avoid drawing overlapping lines + const seenX = new Set(); + for (const ts of tss) { + // valToPos with true = canvas pixel coordinates + const x = u.valToPos(ts, 'x', true); + const rx = Math.round(x); + if (!seenX.has(rx)) { + seenX.add(rx); + u.ctx.beginPath(); + u.ctx.moveTo(x, u.bbox.top); + u.ctx.lineTo(x, u.bbox.top + u.bbox.height); + u.ctx.stroke(); + } + } + + u.ctx.restore(); + }, + }, + }, ], }; + // uplotRef and highlightTimestampMsRef are stable refs — not included in deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [width, height, tickFormatter]); return ( diff --git a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx index e82249c487..51a828dc7b 100644 --- a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx +++ b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx @@ -48,6 +48,16 @@ export function DBSearchHeatmapChart({ }); const [container, setContainer] = useState(null); + // Timestamps highlighted by hovering an attribute value in the delta charts. + // Passed to the heatmap to draw vertical overlay lines. + const [highlightTimestampMs, setHighlightTimestampMs] = useState< + number[] | null + >(null); + + const handleClearSelection = useCallback(() => { + setFields({ xMin: null, xMax: null, yMin: null, yMax: null }); + }, [setFields]); + // After applying a filter, clear the heatmap selection so the delta chart // resets to "all spans" distribution mode instead of staying in comparison mode. const handleAddFilterAndClearSelection = useCallback< @@ -105,6 +115,7 @@ export function DBSearchHeatmapChart({ displayType: DisplayType.Heatmap, }} enabled={isReady} + highlightTimestampMs={highlightTimestampMs} onFilter={(xMin, xMax, yMin, yMax) => { setFields({ xMin, @@ -128,6 +139,8 @@ export function DBSearchHeatmapChart({ onAddFilter={ onAddFilter ? handleAddFilterAndClearSelection : undefined } + onClearSelection={handleClearSelection} + onHighlightTimestamps={setHighlightTimestampMs} /> ); diff --git a/packages/app/src/components/__tests__/DBDeltaChart.test.ts b/packages/app/src/components/__tests__/DBDeltaChart.test.ts index 041f24cec9..0091be7cf4 100644 --- a/packages/app/src/components/__tests__/DBDeltaChart.test.ts +++ b/packages/app/src/components/__tests__/DBDeltaChart.test.ts @@ -1,5 +1,6 @@ import { applyTopNAggregation, + computeDistributionScore, flattenedKeyToSqlExpression, isDenylisted, isHighCardinality, @@ -406,6 +407,68 @@ describe('applyTopNAggregation', () => { }); }); +describe('computeDistributionScore', () => { + it('returns 0 for empty map', () => { + expect(computeDistributionScore(new Map())).toBe(0); + }); + + it('returns 0 for a single-value field (all spans have same value)', () => { + // 1 value at 100% — boring, not useful for filtering + expect(computeDistributionScore(new Map([['production', 100]]))).toBe(0); + }); + + it('returns 0 for a perfectly uniform 2-value field', () => { + // 50% / 50% — uniform, nothing stands out + expect( + computeDistributionScore(new Map([['GET', 50], ['POST', 50]])), + ).toBe(0); + }); + + it('returns high score for a skewed 2-value field', () => { + // 90% / 10% → score = 90 - 50 = 40 + const score = computeDistributionScore( + new Map([['fraud-detection', 90], ['auth', 10]]), + ); + expect(score).toBeCloseTo(40); + }); + + it('ranks more-skewed fields higher than less-skewed fields', () => { + // 80%/20% → score = 80 - 50 = 30 + const scoreA = computeDistributionScore( + new Map([['a', 80], ['b', 20]]), + ); + // 60%/40% → score = 60 - 50 = 10 + const scoreB = computeDistributionScore( + new Map([['a', 60], ['b', 40]]), + ); + expect(scoreA).toBeGreaterThan(scoreB); + }); + + it('returns 0 for a perfectly uniform 3-value field', () => { + // 33%/33%/33% → score = 33 - 33 = 0 + const score = computeDistributionScore( + new Map([['a', 33.33], ['b', 33.33], ['c', 33.33]]), + ); + expect(score).toBeCloseTo(0, 1); + }); + + it('returns positive score for a skewed 3-value field', () => { + // 70%/20%/10% → score = 70 - 33.3 ≈ 36.7 + const score = computeDistributionScore( + new Map([['a', 70], ['b', 20], ['c', 10]]), + ); + expect(score).toBeCloseTo(36.67, 1); + }); + + it('single-value field scores lower than two-value skewed field', () => { + const singleValue = computeDistributionScore(new Map([['only', 100]])); + const skewed = computeDistributionScore( + new Map([['dominant', 95], ['other', 5]]), + ); + expect(skewed).toBeGreaterThan(singleValue); + }); +}); + describe('field split logic (visible vs hidden)', () => { it('correctly classifies a mix of ID, timestamp, cardinality, and useful fields', () => { // TraceId → denylist (ID field, String) From 82c7beb755c3a258642b2543dd454a8d4f099e0a Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:34:15 +0000 Subject: [PATCH 11/43] fix: add flattenedRawData to useMemo destructuring to fix TS build error flattenedRawData was returned from the useMemo but not included in the destructuring pattern, causing a TypeScript 'Cannot find name' error that failed the Next.js build and Vercel deployment. Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBDeltaChart.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index 4e904c43aa..4dc2675246 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -980,6 +980,7 @@ export default function DBDeltaChart({ columnMeta, visibleProperties, hiddenProperties, + flattenedRawData, } = useMemo(() => { const columnMeta = ( outlierData?.meta ?? From bf3feb65184d7d4cfb3755d4a782a3d28a0295e7 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:52:17 +0000 Subject: [PATCH 12/43] fix: resolve filter button, clear selection UX, and correlation highlight prominence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bug 1: "Filter by Selection" now dismisses itself after clicking (calls clearSelectionAndRect) - Bug 2: Clear selection (✕) button moved to heatmap overlay next to "Filter by Selection", clears both URL params and the uPlot selection rectangle (setSelect to zero dimensions); removed X button from legend in DBDeltaChart - Bug 3: Correlation highlight lines thicker (3px) and brighter (gold/yellow at 0.9 opacity); bars in attribute charts dim non-hovered entries to 20% opacity on hover for clear prominence Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBDeltaChart.tsx | 26 +++---- .../app/src/components/DBHeatmapChart.tsx | 75 ++++++++++++++----- .../Search/DBSearchHeatmapChart.tsx | 2 +- 3 files changed, 69 insertions(+), 34 deletions(-) diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index 4dc2675246..1e4605c294 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -17,7 +17,6 @@ import { Filter, } from '@hyperdx/common-utils/dist/types'; import { - ActionIcon, Box, Code, Container, @@ -31,7 +30,6 @@ import { IconCopy, IconFilter, IconFilterX, - IconX, } from '@tabler/icons-react'; import { isAggregateFunction } from '@/ChartUtils'; @@ -521,6 +519,8 @@ function PropertyComparisonChart({ clientY: number; } | null>(null); const [copiedValue, setCopiedValue] = useState(false); + // Local hover state for bar dimming — dims non-hovered bars for prominence + const [hoveredBar, setHoveredBar] = useState(null); const popoverRef = useRef(null); const chartWrapperRef = useRef(null); // Track last hovered bar value to avoid firing onHoverValue on every pixel move @@ -594,6 +594,7 @@ function PropertyComparisonChart({ if (newVal !== lastHoveredBarRef.current) { lastHoveredBarRef.current = newVal; onHoverValue?.(name, newVal); + setHoveredBar(newVal); } }} onMouseLeave={() => { @@ -601,6 +602,7 @@ function PropertyComparisonChart({ lastHoveredBarRef.current = null; onHoverValue?.(name, null); } + setHoveredBar(null); }} > } /> @@ -628,6 +630,9 @@ function PropertyComparisonChart({ ? getChartColorError() : ALL_SPANS_COLOR } + fillOpacity={ + hoveredBar !== null && entry.name !== hoveredBar ? 0.2 : 1 + } /> ))} @@ -642,6 +647,9 @@ function PropertyComparisonChart({ ))} @@ -776,7 +784,6 @@ export default function DBDeltaChart({ yMin: rawYMin, yMax: rawYMax, onAddFilter, - onClearSelection, onHighlightTimestamps, }: { config: ChartConfigWithDateRange; @@ -786,7 +793,6 @@ export default function DBDeltaChart({ yMin?: number | null; yMax?: number | null; onAddFilter?: AddFilterFn; - onClearSelection?: () => void; onHighlightTimestamps?: (timestamps: number[] | null) => void; }) { const hasSelection = @@ -1271,18 +1277,6 @@ export default function DBDeltaChart({ Background - {onClearSelection && ( - - - - )} ) : ( <> diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index f4dd5dc268..4f5856c471 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -284,6 +284,7 @@ function HeatmapContainer({ config, enabled = true, onFilter, + onClearSelection, title, toolbarPrefix, toolbarSuffix, @@ -292,6 +293,7 @@ function HeatmapContainer({ config: HeatmapChartConfig; enabled?: boolean; onFilter?: (xMin: number, xMax: number, yMin: number, yMax: number) => void; + onClearSelection?: () => void; title?: React.ReactNode; toolbarPrefix?: React.ReactNode[]; toolbarSuffix?: React.ReactNode[]; @@ -557,6 +559,7 @@ function HeatmapContainer({ data={[time, bucket, count]} numberFormat={config.numberFormat} onFilter={onFilter} + onClearSelection={onClearSelection} highlightTimestampMs={highlightTimestampMs} /> )} @@ -669,11 +672,13 @@ function Heatmap({ data, numberFormat, onFilter, + onClearSelection, highlightTimestampMs, }: { data: Mode2DataArray; numberFormat?: NumberFormat; onFilter?: (xMin: number, xMax: number, yMin: number, yMax: number) => void; + onClearSelection?: () => void; highlightTimestampMs?: number[] | null; }) { const [selectingInfo, setSelectingInfo] = useState< @@ -694,6 +699,17 @@ function Heatmap({ // Refs for correlation highlight overlay: uPlot instance + latest highlight timestamps const uplotRef = useRef(null); + + // Clears the React selection state AND the uPlot selection rectangle + const clearSelectionAndRect = useCallback(() => { + setSelectingInfo(undefined); + if (uplotRef.current) { + try { + uplotRef.current.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false); + } catch (_) {} + } + onClearSelection?.(); + }, [onClearSelection]); const highlightTimestampMsRef = useRef(null); // Keep ref in sync with latest prop value on every render highlightTimestampMsRef.current = highlightTimestampMs ?? null; @@ -846,8 +862,8 @@ function Heatmap({ u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); u.ctx.clip(); - u.ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)'; - u.ctx.lineWidth = 1.5; + u.ctx.strokeStyle = 'rgba(255, 220, 50, 0.9)'; + u.ctx.lineWidth = 3; // Deduplicate pixel positions to avoid drawing overlapping lines const seenX = new Set(); @@ -946,30 +962,55 @@ function Heatmap({
)} - {selectingInfo != null && onFilter != null && ( + {selectingInfo != null && (
{ - e.stopPropagation(); - onFilter?.( - selectingInfo.xMin / 1000, - selectingInfo.xMax / 1000, - selectingInfo.yMin, - selectingInfo.yMax, - ); - }} - role="button" > - Filter by Selection + {onFilter != null && ( +
{ + e.stopPropagation(); + onFilter( + selectingInfo.xMin / 1000, + selectingInfo.xMax / 1000, + selectingInfo.yMin, + selectingInfo.yMax, + ); + clearSelectionAndRect(); + }} + > + Filter by Selection +
+ )} +
{ + e.stopPropagation(); + clearSelectionAndRect(); + }} + > + ✕ +
)} diff --git a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx index 51a828dc7b..626790c76a 100644 --- a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx +++ b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx @@ -124,6 +124,7 @@ export function DBSearchHeatmapChart({ yMax, }); }} + onClearSelection={handleClearSelection} />
From fff91c752428c54eb87dc41afbb0b43eb96e7071 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 01:29:55 +0000 Subject: [PATCH 13/43] fix: resolve selection UX, percentage calculation, and correlation highlight Bug 1 (can't re-select): clearSelectionAndRect() calls u.setSelect({width:0}, false) to erase the visual rectangle. uPlot still fires the setSelect plugin hook even with fire=false, causing setSelectingInfo to be called with a zero-size selection and leaving a phantom overlay. Fix: check u.select.width>0 in the hook before calling setSelectingInfo. Bug 2 (percentage inconsistency): getPropertyStatistics divided counts by propertyOccurences (spans with this property), making a single-valued field show 100% even if it only appeared in 30% of spans. Fix: divide by data.length (total spans) so percentages mean "fraction of ALL spans". Updated computeDistributionScore to use mean(pcts) as uniform baseline instead of 100/n so sorting still works when percentages don't sum to 100. Bug 3 (correlation highlight): Changed onHighlightTimestamps (number[]) to onHighlightPoints ({tsMs, yValue}[]). Added computeYValue() to parse simple SQL expressions ("Col", "Col / N", "Col * N") and extract the Y metric value from raw span data. Updated the draw hook in Heatmap to draw filled semi-transparent rectangles snapped to the correct heatmap cell instead of full-height vertical lines. Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBDeltaChart.tsx | 97 ++++++++++++---- .../app/src/components/DBHeatmapChart.tsx | 105 +++++++++++++----- .../Search/DBSearchHeatmapChart.tsx | 14 +-- .../components/__tests__/DBDeltaChart.test.ts | 50 +++++++++ 4 files changed, 210 insertions(+), 56 deletions(-) diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index 1e4605c294..b77e0d25db 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -301,14 +301,15 @@ function getPropertyStatistics(data: Record[]) { }); }); + // Divide by total rows so percentages represent "fraction of ALL spans", + // not "fraction of spans that have this property". This ensures a single-valued + // field that only appears in 30% of spans shows 30%, not 100%. + const totalRows = data.length || 1; const percentageOccurences = new Map>(); valueOccurences.forEach((valuesMap, property) => { const percentageMap = new Map(); valuesMap.forEach((valueCount, value) => { - percentageMap.set( - value, - (valueCount / (propertyOccurences.get(property) ?? 0)) * 100, - ); + percentageMap.set(value, (valueCount / totalRows) * 100); }); percentageOccurences.set(property, percentageMap); }); @@ -362,11 +363,17 @@ export function computeDistributionScore( ): number { const nValues = valuePercentages.size; if (nValues <= 1) return 0; - const uniformExpected = 100 / nValues; + // Use mean(pcts) as the uniform baseline rather than 100/n so the score + // works correctly even when percentages don't sum to 100 (e.g., when + // each value's % is computed relative to ALL spans, not just spans with this property). + let totalPct = 0; let maxPct = 0; valuePercentages.forEach(pct => { + totalPct += pct; if (pct > maxPct) maxPct = pct; }); + if (totalPct === 0) return 0; + const uniformExpected = totalPct / nValues; return maxPct - uniformExpected; } @@ -376,6 +383,55 @@ export type AddFilterFn = ( action?: 'only' | 'exclude' | 'include', ) => void; +export type HighlightPoint = { tsMs: number; yValue: number | null }; + +/** + * Tries to compute the heatmap Y-axis value for a span from a raw flattened row. + * Handles simple SQL expressions: "ColName", "ColName / N", "ColName * N". + * Returns null if the expression is too complex or the column is missing. + */ +export function computeYValue( + valueExpr: string, + flatRow: Record, +): number | null { + const trimmed = valueExpr.trim(); + + // Simple column reference: "Duration" + const simpleMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)$/); + if (simpleMatch) { + const v = flatRow[simpleMatch[1]]; + if (v == null) return null; + const n = Number(v); + return isNaN(n) ? null : n; + } + + // Division: "Duration / 1000000" + const divMatch = trimmed.match( + /^([A-Za-z_][A-Za-z0-9_]*)\s*\/\s*([0-9]+(?:\.[0-9]+)?(?:e[+-]?[0-9]+)?)$/i, + ); + if (divMatch) { + const v = flatRow[divMatch[1]]; + if (v == null) return null; + const n = Number(v); + const d = parseFloat(divMatch[2]); + return isNaN(n) || isNaN(d) || d === 0 ? null : n / d; + } + + // Multiplication: "Duration * 0.001" + const mulMatch = trimmed.match( + /^([A-Za-z_][A-Za-z0-9_]*)\s*\*\s*([0-9]+(?:\.[0-9]+)?(?:e[+-]?[0-9]+)?)$/i, + ); + if (mulMatch) { + const v = flatRow[mulMatch[1]]; + if (v == null) return null; + const n = Number(v); + const m = parseFloat(mulMatch[2]); + return isNaN(n) || isNaN(m) ? null : n * m; + } + + return null; +} + // Hover-only tooltip: shows value name and percentages, no action buttons. // Actions are handled by the click popover in PropertyComparisonChart. const HDXBarChartTooltip = withErrorBoundary( @@ -784,7 +840,7 @@ export default function DBDeltaChart({ yMin: rawYMin, yMax: rawYMax, onAddFilter, - onHighlightTimestamps, + onHighlightPoints, }: { config: ChartConfigWithDateRange; valueExpr: string; @@ -793,7 +849,7 @@ export default function DBDeltaChart({ yMin?: number | null; yMax?: number | null; onAddFilter?: AddFilterFn; - onHighlightTimestamps?: (timestamps: number[] | null) => void; + onHighlightPoints?: (points: HighlightPoint[] | null) => void; }) { const hasSelection = rawXMin != null && rawXMax != null && rawYMin != null && rawYMax != null; @@ -1115,9 +1171,9 @@ export default function DBDeltaChart({ [], ); - // Compute timestamps of spans matching the hovered attribute value. - // Used to highlight corresponding time positions on the heatmap. - const highlightTimestampMs = useMemo(() => { + // Compute {tsMs, yValue} pairs for spans matching the hovered attribute value. + // Used to highlight corresponding heatmap cells (correct X + Y position). + const highlightPoints = useMemo((): HighlightPoint[] | null => { if (!hoveredAttributeValue) return null; const { property, value } = hoveredAttributeValue; @@ -1130,23 +1186,26 @@ export default function DBDeltaChart({ )?.name; if (!tsColName) return null; - const timestamps: number[] = []; + const points: HighlightPoint[] = []; for (const flat of flattenedRawData) { if (String(flat[property]) === value) { const ts = flat[tsColName]; if (ts != null) { const tsMs = new Date(ts as string).getTime(); - if (!isNaN(tsMs)) timestamps.push(tsMs); + if (!isNaN(tsMs)) { + const yValue = computeYValue(valueExpr, flat); + points.push({ tsMs, yValue }); + } } } } - return timestamps.length > 0 ? timestamps : null; - }, [hoveredAttributeValue, flattenedRawData, columnMeta]); + return points.length > 0 ? points : null; + }, [hoveredAttributeValue, flattenedRawData, columnMeta, valueExpr]); - // Propagate highlight timestamps to parent (e.g., DBSearchHeatmapChart) + // Propagate highlight points to parent (e.g., DBSearchHeatmapChart) useEffect(() => { - onHighlightTimestamps?.(highlightTimestampMs); - }, [highlightTimestampMs, onHighlightTimestamps]); + onHighlightPoints?.(highlightPoints); + }, [highlightPoints, onHighlightPoints]); const [activePage, setPage] = useState(1); @@ -1320,7 +1379,7 @@ export default function DBDeltaChart({ } onAddFilter={onAddFilter ? handleAddFilter : undefined} hasSelection={hasSelection} - onHoverValue={onHighlightTimestamps ? handleHoverValue : undefined} + onHoverValue={onHighlightPoints ? handleHoverValue : undefined} key={property} /> ))} @@ -1359,7 +1418,7 @@ export default function DBDeltaChart({ } onAddFilter={onAddFilter ? handleAddFilter : undefined} hasSelection={hasSelection} - onHoverValue={onHighlightTimestamps ? handleHoverValue : undefined} + onHoverValue={onHighlightPoints ? handleHoverValue : undefined} key={key} /> ))} diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index 4f5856c471..f0d55cb9d4 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -288,7 +288,7 @@ function HeatmapContainer({ title, toolbarPrefix, toolbarSuffix, - highlightTimestampMs, + highlightPoints, }: { config: HeatmapChartConfig; enabled?: boolean; @@ -297,7 +297,7 @@ function HeatmapContainer({ title?: React.ReactNode; toolbarPrefix?: React.ReactNode[]; toolbarSuffix?: React.ReactNode[]; - highlightTimestampMs?: number[] | null; + highlightPoints?: { tsMs: number; yValue: number | null }[] | null; }) { const dateRange = config.dateRange; const granularity = convertDateRangeToGranularityString(dateRange, 245); @@ -560,7 +560,7 @@ function HeatmapContainer({ numberFormat={config.numberFormat} onFilter={onFilter} onClearSelection={onClearSelection} - highlightTimestampMs={highlightTimestampMs} + highlightPoints={highlightPoints} /> )} @@ -673,13 +673,13 @@ function Heatmap({ numberFormat, onFilter, onClearSelection, - highlightTimestampMs, + highlightPoints, }: { data: Mode2DataArray; numberFormat?: NumberFormat; onFilter?: (xMin: number, xMax: number, yMin: number, yMax: number) => void; onClearSelection?: () => void; - highlightTimestampMs?: number[] | null; + highlightPoints?: { tsMs: number; yValue: number | null }[] | null; }) { const [selectingInfo, setSelectingInfo] = useState< | { @@ -710,16 +710,16 @@ function Heatmap({ } onClearSelection?.(); }, [onClearSelection]); - const highlightTimestampMsRef = useRef(null); + const highlightPointsRef = useRef<{ tsMs: number; yValue: number | null }[] | null>(null); // Keep ref in sync with latest prop value on every render - highlightTimestampMsRef.current = highlightTimestampMs ?? null; + highlightPointsRef.current = highlightPoints ?? null; - // Trigger a uPlot redraw when highlight timestamps change so the draw hook re-runs + // Trigger a uPlot redraw when highlight points change so the draw hook re-runs useEffect(() => { if (uplotRef.current) { uplotRef.current.redraw(false); } - }, [highlightTimestampMs]); + }, [highlightPoints]); const [highlightedPoint, setHighlightedPoint] = useState< | { @@ -819,6 +819,15 @@ function Heatmap({ { hooks: { setSelect: u => { + // clearSelectionAndRect() calls u.setSelect({width:0,height:0}, false) to + // erase the visual rectangle. Even with fire=false, uPlot still fires this + // plugin hook. Guard against zero-size selections so the overlay doesn't + // reappear as a phantom after the user clears it. + if (u.select.width <= 0 || u.select.height <= 0) { + setSelectingInfo(undefined); + return; + } + // Calculate offset from parent so we can render tooltip // relative to the parent pixels const { offsetLeft, offsetTop } = u.over; @@ -846,37 +855,73 @@ function Heatmap({ }, }, { - // Correlation highlight: draws vertical lines on the heatmap canvas - // at the timestamps of spans matching the hovered attribute value. + // Correlation highlight: draws filled rectangles on the heatmap canvas + // at the cells corresponding to spans matching the hovered attribute value. + // When Y values are available, highlights the precise heatmap cell (correct + // X + Y bucket). Falls back to a full-height column when Y is unavailable. hooks: { init: (u: uPlot) => { // eslint-disable-next-line react-hooks/exhaustive-deps uplotRef.current = u; }, draw: (u: uPlot) => { - const tss = highlightTimestampMsRef.current; - if (!tss?.length) return; + const pts = highlightPointsRef.current; + if (!pts?.length) return; + + // Derive bin sizes from the mode-2 heatmap data + const [xs, ys] = u.data[1] as unknown as Mode2DataArray; + if (!xs.length || !ys.length) return; + + const dlen = xs.length; + const yBinQty = dlen - ys.lastIndexOf(ys[0]); + const yBinIncr = ys[1] - ys[0]; // positive value increment per bin + const xBinIncr = xs[yBinQty] - xs[0]; // ms increment per time bucket + + // Pixel size of one bin (canvas coordinates, always positive) + const xSizePx = Math.abs( + u.valToPos(xBinIncr, 'x', true) - u.valToPos(0, 'x', true), + ); + // For Y: larger values → smaller canvas y (inverted axis) + const ySizePx = Math.abs( + u.valToPos(yBinIncr, 'y', true) - u.valToPos(0, 'y', true), + ); u.ctx.save(); - // Clip to the plot area u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); u.ctx.clip(); - u.ctx.strokeStyle = 'rgba(255, 220, 50, 0.9)'; - u.ctx.lineWidth = 3; - - // Deduplicate pixel positions to avoid drawing overlapping lines - const seenX = new Set(); - for (const ts of tss) { - // valToPos with true = canvas pixel coordinates - const x = u.valToPos(ts, 'x', true); - const rx = Math.round(x); - if (!seenX.has(rx)) { - seenX.add(rx); - u.ctx.beginPath(); - u.ctx.moveTo(x, u.bbox.top); - u.ctx.lineTo(x, u.bbox.top + u.bbox.height); - u.ctx.stroke(); + u.ctx.fillStyle = 'rgba(255, 220, 50, 0.6)'; + + // Deduplicate drawn cells to avoid overdraw + const drawnCells = new Set(); + + for (const { tsMs, yValue } of pts) { + const xPx = u.valToPos(tsMs, 'x', true); + const xLeft = xPx - xSizePx / 2; + + if (yValue != null) { + // Snap to nearest Y bin boundary so the rect aligns with heatmap cells. + // ys[0] is the first bin start value; bins increase by yBinIncr. + const binIdx = Math.max( + 0, + Math.round((yValue - ys[0]) / yBinIncr), + ); + const binStartValue = ys[0] + binIdx * yBinIncr; + // In canvas coords, larger value → smaller y (inverted). + // Top of bin = position of (binStart + yBinIncr), bottom of bin = position of binStart. + const topPx = u.valToPos(binStartValue + yBinIncr, 'y', true); + const cellKey = `${Math.round(xLeft)},${Math.round(topPx)}`; + if (!drawnCells.has(cellKey)) { + drawnCells.add(cellKey); + u.ctx.fillRect(xLeft, topPx, xSizePx, ySizePx); + } + } else { + // No Y value available — highlight the full time column + const cellKey = `x:${Math.round(xLeft)}`; + if (!drawnCells.has(cellKey)) { + drawnCells.add(cellKey); + u.ctx.fillRect(xLeft, u.bbox.top, xSizePx, u.bbox.height); + } } } @@ -886,7 +931,7 @@ function Heatmap({ }, ], }; - // uplotRef and highlightTimestampMsRef are stable refs — not included in deps + // uplotRef and highlightPointsRef are stable refs — not included in deps // eslint-disable-next-line react-hooks/exhaustive-deps }, [width, height, tickFormatter]); diff --git a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx index 626790c76a..d7761b89da 100644 --- a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx +++ b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx @@ -17,7 +17,7 @@ import { IconPlayerPlay } from '@tabler/icons-react'; import { getDurationMsExpression } from '@/source'; -import DBDeltaChart, { AddFilterFn } from '../DBDeltaChart'; +import DBDeltaChart, { AddFilterFn, HighlightPoint } from '../DBDeltaChart'; import DBHeatmapChart from '../DBHeatmapChart'; import { SQLInlineEditorControlled } from '../SQLInlineEditor'; @@ -48,10 +48,10 @@ export function DBSearchHeatmapChart({ }); const [container, setContainer] = useState(null); - // Timestamps highlighted by hovering an attribute value in the delta charts. - // Passed to the heatmap to draw vertical overlay lines. - const [highlightTimestampMs, setHighlightTimestampMs] = useState< - number[] | null + // Highlight points from hovering an attribute value in the delta charts. + // Passed to the heatmap to draw filled cell overlays at the correct X+Y position. + const [highlightPoints, setHighlightPoints] = useState< + HighlightPoint[] | null >(null); const handleClearSelection = useCallback(() => { @@ -115,7 +115,7 @@ export function DBSearchHeatmapChart({ displayType: DisplayType.Heatmap, }} enabled={isReady} - highlightTimestampMs={highlightTimestampMs} + highlightPoints={highlightPoints} onFilter={(xMin, xMax, yMin, yMax) => { setFields({ xMin, @@ -140,7 +140,7 @@ export function DBSearchHeatmapChart({ onAddFilter={ onAddFilter ? handleAddFilterAndClearSelection : undefined } - onHighlightTimestamps={setHighlightTimestampMs} + onHighlightPoints={setHighlightPoints} />
); diff --git a/packages/app/src/components/__tests__/DBDeltaChart.test.ts b/packages/app/src/components/__tests__/DBDeltaChart.test.ts index 0091be7cf4..1d9dd738a6 100644 --- a/packages/app/src/components/__tests__/DBDeltaChart.test.ts +++ b/packages/app/src/components/__tests__/DBDeltaChart.test.ts @@ -1,6 +1,7 @@ import { applyTopNAggregation, computeDistributionScore, + computeYValue, flattenedKeyToSqlExpression, isDenylisted, isHighCardinality, @@ -467,6 +468,55 @@ describe('computeDistributionScore', () => { ); expect(skewed).toBeGreaterThan(singleValue); }); + + it('works correctly when percentages do not sum to 100 (all-spans mode)', () => { + // After the percentage fix, values represent % of ALL spans. + // Property "env" appears in 60% of spans: prod=40%, staging=20%. + // Score should be positive (skewed), not zero. + const score = computeDistributionScore(new Map([['prod', 40], ['staging', 20]])); + // mean = 30, max = 40, score = 10 + expect(score).toBeCloseTo(10); + }); + + it('returns 0 for a single-valued property that appears in only 30% of spans', () => { + // 1 value at 30% (of all spans) — nValues=1, returns 0 regardless + expect(computeDistributionScore(new Map([['production', 30]]))).toBe(0); + }); +}); + +describe('computeYValue', () => { + it('returns value for simple column reference', () => { + expect(computeYValue('Duration', { Duration: 5000000 })).toBe(5000000); + }); + + it('handles string-encoded numbers (ClickHouse UInt64)', () => { + expect(computeYValue('Duration', { Duration: '5000000' })).toBe(5000000); + }); + + it('handles division expression "Col / N"', () => { + expect(computeYValue('Duration / 1000000', { Duration: 5000000 })).toBeCloseTo(5); + }); + + it('handles multiplication expression "Col * N"', () => { + expect(computeYValue('Duration * 0.001', { Duration: 5000000 })).toBeCloseTo(5000); + }); + + it('handles scientific notation divisor', () => { + expect(computeYValue('Duration / 1e6', { Duration: 5000000 })).toBeCloseTo(5); + }); + + it('returns null when column is missing', () => { + expect(computeYValue('Duration / 1000000', { OtherCol: 42 })).toBeNull(); + }); + + it('returns null for complex expressions it cannot parse', () => { + expect(computeYValue('count()', {})).toBeNull(); + expect(computeYValue('toUnixTimestamp(Timestamp)', {})).toBeNull(); + }); + + it('returns null when divisor is zero', () => { + expect(computeYValue('Duration / 0', { Duration: 5000 })).toBeNull(); + }); }); describe('field split logic (visible vs hidden)', () => { From cea73f151d1b938348262670a62265affea7d2a8 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 01:33:06 +0000 Subject: [PATCH 14/43] fix: row-based pagination for visible and hidden field sections Each section is rendered in its own CSS grid, so hidden fields always start at column 1 regardless of how many columns the visible section fills on its last row. Replace item-count pagination (totalProperties / PAGE_SIZE) with row-count pagination (visibleRows + hiddenRows / rows). Each section's items-on-page are derived from its own row slice, ensuring correct column alignment and accurate page count. Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBDeltaChart.tsx | 40 ++++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index b77e0d25db..3635e7e7e6 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -1267,25 +1267,33 @@ export default function DBDeltaChart({ ); } - // Include lower-priority fields in pagination so they don't create an - // unbounded last page. All fields are paginated together; a divider separates - // the two groups on pages that contain both. - const totalProperties = visibleProperties.length + hiddenProperties.length; - const totalPages = Math.ceil(totalProperties / PAGE_SIZE); - - const pageStart = (activePage - 1) * PAGE_SIZE; - const pageEnd = activePage * PAGE_SIZE; - - // Primary (visible) fields on the current page + // Paginate by ROWS, not by item count, because visible and hidden fields are + // rendered in separate CSS grids. An incomplete last row of the visible + // section must not be "filled" with hidden items — each section always starts + // from column 1. + const visibleRows = Math.ceil(visibleProperties.length / columns); + const hiddenRows = Math.ceil(hiddenProperties.length / columns); + const totalRows = visibleRows + hiddenRows; + const totalPages = Math.ceil(totalRows / rows); + + const pageRowStart = (activePage - 1) * rows; + const pageRowEnd = activePage * rows; + + // Rows occupied by the visible section on this page + const visRowStart = Math.min(pageRowStart, visibleRows); + const visRowEnd = Math.min(pageRowEnd, visibleRows); const visibleOnPage = visibleProperties.slice( - Math.max(0, pageStart), - Math.min(visibleProperties.length, pageEnd), + visRowStart * columns, + Math.min(visRowEnd * columns, visibleProperties.length), ); - // Lower-priority (hidden) fields on the current page - const hiddenPageStart = Math.max(0, pageStart - visibleProperties.length); - const hiddenPageEnd = Math.max(0, pageEnd - visibleProperties.length); - const hiddenOnPage = hiddenProperties.slice(hiddenPageStart, hiddenPageEnd); + // Rows occupied by the hidden section on this page + const hidRowStart = Math.max(0, pageRowStart - visibleRows); + const hidRowEnd = Math.min(hiddenRows, Math.max(0, pageRowEnd - visibleRows)); + const hiddenOnPage = hiddenProperties.slice( + hidRowStart * columns, + Math.min(hidRowEnd * columns, hiddenProperties.length), + ); // Show a divider when both sections appear on the same page const showDivider = visibleOnPage.length > 0 && hiddenOnPage.length > 0; From 35c810095eeed21999bf27ca456eecb94b333fa8 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 01:43:17 +0000 Subject: [PATCH 15/43] fix: correct correlation highlight vertical extent on heatmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-part fix for the heatmap correlation highlight drawing full-height columns instead of just the duration range of matching spans: 1. computeYValue now handles parenthesized column expressions like (Duration)/1e6 — the format produced by getDurationMsExpression. Previously the regex required a bare identifier at the start, causing null returns and a full-height column fallback for every span. 2. The draw hook in Heatmap now groups spans by X time column and draws a single continuous rectangle from min(duration) to max(duration) per column, instead of individual cells per span. Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBDeltaChart.tsx | 17 ++-- .../app/src/components/DBHeatmapChart.tsx | 89 ++++++++++++------- .../components/__tests__/DBDeltaChart.test.ts | 15 ++++ 3 files changed, 84 insertions(+), 37 deletions(-) diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index 3635e7e7e6..585e99016f 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -396,8 +396,12 @@ export function computeYValue( ): number | null { const trimmed = valueExpr.trim(); - // Simple column reference: "Duration" - const simpleMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)$/); + // Identifier pattern (with optional surrounding parentheses): + // Matches "ColName" and "(ColName)" + const identPat = '\\(?([A-Za-z_][A-Za-z0-9_]*)\\)?'; + + // Simple column reference: "Duration" or "(Duration)" + const simpleMatch = trimmed.match(new RegExp(`^${identPat}$`)); if (simpleMatch) { const v = flatRow[simpleMatch[1]]; if (v == null) return null; @@ -405,9 +409,10 @@ export function computeYValue( return isNaN(n) ? null : n; } - // Division: "Duration / 1000000" + // Division: "Duration / 1000000", "(Duration)/1e6", "(Duration) / 1e6" + const numPat = '([0-9]+(?:\\.[0-9]+)?(?:e[+-]?[0-9]+)?)'; const divMatch = trimmed.match( - /^([A-Za-z_][A-Za-z0-9_]*)\s*\/\s*([0-9]+(?:\.[0-9]+)?(?:e[+-]?[0-9]+)?)$/i, + new RegExp(`^${identPat}\\s*\\/\\s*${numPat}$`, 'i'), ); if (divMatch) { const v = flatRow[divMatch[1]]; @@ -417,9 +422,9 @@ export function computeYValue( return isNaN(n) || isNaN(d) || d === 0 ? null : n / d; } - // Multiplication: "Duration * 0.001" + // Multiplication: "Duration * 0.001", "(Duration) * 0.001" const mulMatch = trimmed.match( - /^([A-Za-z_][A-Za-z0-9_]*)\s*\*\s*([0-9]+(?:\.[0-9]+)?(?:e[+-]?[0-9]+)?)$/i, + new RegExp(`^${identPat}\\s*\\*\\s*${numPat}$`, 'i'), ); if (mulMatch) { const v = flatRow[mulMatch[1]]; diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index f0d55cb9d4..3c944a6282 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -856,9 +856,10 @@ function Heatmap({ }, { // Correlation highlight: draws filled rectangles on the heatmap canvas - // at the cells corresponding to spans matching the hovered attribute value. - // When Y values are available, highlights the precise heatmap cell (correct - // X + Y bucket). Falls back to a full-height column when Y is unavailable. + // at the columns corresponding to spans matching the hovered attribute value. + // Groups spans by X time column and draws a single continuous rect from + // min(duration) to max(duration) per column, so the highlight only covers + // the actual duration range of matching spans — not the full chart height. hooks: { init: (u: uPlot) => { // eslint-disable-next-line react-hooks/exhaustive-deps @@ -881,47 +882,73 @@ function Heatmap({ const xSizePx = Math.abs( u.valToPos(xBinIncr, 'x', true) - u.valToPos(0, 'x', true), ); - // For Y: larger values → smaller canvas y (inverted axis) const ySizePx = Math.abs( u.valToPos(yBinIncr, 'y', true) - u.valToPos(0, 'y', true), ); + // Group spans by their pixel-snapped X column so we can draw a single + // continuous rect per time column covering [minY, maxY] of matching spans. + type ColumnInfo = { + xLeft: number; + minY: number | null; + maxY: number | null; + }; + const columnMap = new Map(); + + for (const { tsMs, yValue } of pts) { + const xLeft = Math.round( + u.valToPos(tsMs, 'x', true) - xSizePx / 2, + ); + const existing = columnMap.get(xLeft); + if (existing) { + if (yValue != null) { + existing.minY = + existing.minY == null + ? yValue + : Math.min(existing.minY, yValue); + existing.maxY = + existing.maxY == null + ? yValue + : Math.max(existing.maxY, yValue); + } + } else { + columnMap.set(xLeft, { xLeft, minY: yValue, maxY: yValue }); + } + } + u.ctx.save(); u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); u.ctx.clip(); u.ctx.fillStyle = 'rgba(255, 220, 50, 0.6)'; - // Deduplicate drawn cells to avoid overdraw - const drawnCells = new Set(); - - for (const { tsMs, yValue } of pts) { - const xPx = u.valToPos(tsMs, 'x', true); - const xLeft = xPx - xSizePx / 2; - - if (yValue != null) { - // Snap to nearest Y bin boundary so the rect aligns with heatmap cells. + for (const { xLeft, minY, maxY } of columnMap.values()) { + if (minY == null || maxY == null) { + // No Y values available — highlight the full time column + u.ctx.fillRect(xLeft, u.bbox.top, xSizePx, u.bbox.height); + } else { + // Snap min/max to bin boundaries so rects align with heatmap cells. // ys[0] is the first bin start value; bins increase by yBinIncr. - const binIdx = Math.max( + const minBinIdx = Math.max( 0, - Math.round((yValue - ys[0]) / yBinIncr), + Math.round((minY - ys[0]) / yBinIncr), ); - const binStartValue = ys[0] + binIdx * yBinIncr; - // In canvas coords, larger value → smaller y (inverted). - // Top of bin = position of (binStart + yBinIncr), bottom of bin = position of binStart. - const topPx = u.valToPos(binStartValue + yBinIncr, 'y', true); - const cellKey = `${Math.round(xLeft)},${Math.round(topPx)}`; - if (!drawnCells.has(cellKey)) { - drawnCells.add(cellKey); - u.ctx.fillRect(xLeft, topPx, xSizePx, ySizePx); - } - } else { - // No Y value available — highlight the full time column - const cellKey = `x:${Math.round(xLeft)}`; - if (!drawnCells.has(cellKey)) { - drawnCells.add(cellKey); - u.ctx.fillRect(xLeft, u.bbox.top, xSizePx, u.bbox.height); - } + const maxBinIdx = Math.max( + 0, + Math.round((maxY - ys[0]) / yBinIncr), + ); + const minBinStart = ys[0] + minBinIdx * yBinIncr; + const maxBinEnd = ys[0] + (maxBinIdx + 1) * yBinIncr; + + // In canvas coords: larger Y value → smaller pixel y (inverted axis). + // Top of highest bin = valToPos(maxBinEnd), + // bottom of lowest bin = valToPos(minBinStart). + const topPx = u.valToPos(maxBinEnd, 'y', true); + const bottomPx = u.valToPos(minBinStart, 'y', true); + // Ensure at least one bin height is drawn + const heightPx = Math.max(ySizePx, bottomPx - topPx); + + u.ctx.fillRect(xLeft, topPx, xSizePx, heightPx); } } diff --git a/packages/app/src/components/__tests__/DBDeltaChart.test.ts b/packages/app/src/components/__tests__/DBDeltaChart.test.ts index 1d9dd738a6..1d981e2e90 100644 --- a/packages/app/src/components/__tests__/DBDeltaChart.test.ts +++ b/packages/app/src/components/__tests__/DBDeltaChart.test.ts @@ -497,10 +497,25 @@ describe('computeYValue', () => { expect(computeYValue('Duration / 1000000', { Duration: 5000000 })).toBeCloseTo(5); }); + it('handles parenthesized column in division "(Col)/N" (getDurationMsExpression format)', () => { + expect(computeYValue('(Duration)/1e6', { Duration: 5000000 })).toBeCloseTo(5); + expect(computeYValue('(Duration) / 1e6', { Duration: 5000000 })).toBeCloseTo(5); + expect(computeYValue('(Duration)/1000000', { Duration: 5000000 })).toBeCloseTo(5); + }); + + it('handles simple parenthesized column reference "(Col)"', () => { + expect(computeYValue('(Duration)', { Duration: 5000000 })).toBe(5000000); + }); + it('handles multiplication expression "Col * N"', () => { expect(computeYValue('Duration * 0.001', { Duration: 5000000 })).toBeCloseTo(5000); }); + it('handles parenthesized column in multiplication "(Col) * N"', () => { + expect(computeYValue('(Duration) * 0.001', { Duration: 5000000 })).toBeCloseTo(5000); + expect(computeYValue('(Duration)*0.001', { Duration: 5000000 })).toBeCloseTo(5000); + }); + it('handles scientific notation divisor', () => { expect(computeYValue('Duration / 1e6', { Duration: 5000000 })).toBeCloseTo(5); }); From 9b89af35275d227b41341ba9f42c2b8ae902649b Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 02:57:10 +0000 Subject: [PATCH 16/43] fix: resolve filter button and correlation highlight bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1: "Filter by Selection" was calling clearSelectionAndRect() which invokes onClearSelection → resets xMin/xMax/yMin/yMax to null immediately after onFilter sets them, so the filter was never applied. Fix: dismiss the visual overlay directly without triggering onClearSelection. Bug 2: Correlation highlight cells were invisible because xSizePx/ySizePx were computed using valToPos(0) and valToPos(xBinIncr) — values far outside the actual time scale range (epoch ms ~1.7e12). This produced wrong pixel sizes. Fix: compute sizes using actual data points from xs/ys arrays (same approach as heatmapPaths), and draw individual cells using exact xs[xi]/ys[yi] bucket values matching the heatmapPaths coordinate calculation precisely. Co-authored-by: Alex Fedotyev --- .../app/src/components/DBHeatmapChart.tsx | 136 +++++++++--------- 1 file changed, 70 insertions(+), 66 deletions(-) diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index 3c944a6282..1dfce67703 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -856,10 +856,9 @@ function Heatmap({ }, { // Correlation highlight: draws filled rectangles on the heatmap canvas - // at the columns corresponding to spans matching the hovered attribute value. - // Groups spans by X time column and draws a single continuous rect from - // min(duration) to max(duration) per column, so the highlight only covers - // the actual duration range of matching spans — not the full chart height. + // at cells corresponding to spans matching the hovered attribute value. + // Mirrors heatmapPaths coordinate calculation exactly so highlights align + // with the heatmap cells they represent. hooks: { init: (u: uPlot) => { // eslint-disable-next-line react-hooks/exhaustive-deps @@ -869,87 +868,81 @@ function Heatmap({ const pts = highlightPointsRef.current; if (!pts?.length) return; - // Derive bin sizes from the mode-2 heatmap data + // Derive bin geometry from the mode-2 heatmap data const [xs, ys] = u.data[1] as unknown as Mode2DataArray; - if (!xs.length || !ys.length) return; + if (!xs?.length || !ys?.length || xs.length < 2 || ys.length < 2) + return; const dlen = xs.length; const yBinQty = dlen - ys.lastIndexOf(ys[0]); + if (yBinQty < 2) return; + const xBinQty = Math.floor(dlen / yBinQty); const yBinIncr = ys[1] - ys[0]; // positive value increment per bin const xBinIncr = xs[yBinQty] - xs[0]; // ms increment per time bucket + if (yBinIncr === 0 || xBinIncr === 0) return; - // Pixel size of one bin (canvas coordinates, always positive) + // Compute pixel cell size using actual data points (same as heatmapPaths). + // Using xs[0]/ys[0] as reference avoids issues with values far outside + // the visible scale range (e.g. valToPos(0) on a ms-epoch time axis). const xSizePx = Math.abs( - u.valToPos(xBinIncr, 'x', true) - u.valToPos(0, 'x', true), + u.valToPos(xs[0] + xBinIncr, 'x', true) - + u.valToPos(xs[0], 'x', true), ); const ySizePx = Math.abs( - u.valToPos(yBinIncr, 'y', true) - u.valToPos(0, 'y', true), + u.valToPos(ys[0] + yBinIncr, 'y', true) - + u.valToPos(ys[0], 'y', true), ); - // Group spans by their pixel-snapped X column so we can draw a single - // continuous rect per time column covering [minY, maxY] of matching spans. - type ColumnInfo = { - xLeft: number; - minY: number | null; - maxY: number | null; - }; - const columnMap = new Map(); + u.ctx.save(); + u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + u.ctx.clip(); + u.ctx.fillStyle = 'rgba(255, 220, 50, 0.6)'; + + // Deduplicate by (xi, yi) cell so we don't overdraw the same cell. + const drawnCells = new Set(); for (const { tsMs, yValue } of pts) { - const xLeft = Math.round( - u.valToPos(tsMs, 'x', true) - xSizePx / 2, + // Find which x bucket this timestamp falls into + const xi = Math.max( + 0, + Math.min( + xBinQty - 1, + Math.round((tsMs - xs[0]) / xBinIncr), + ), ); - const existing = columnMap.get(xLeft); - if (existing) { - if (yValue != null) { - existing.minY = - existing.minY == null - ? yValue - : Math.min(existing.minY, yValue); - existing.maxY = - existing.maxY == null - ? yValue - : Math.max(existing.maxY, yValue); + // Use the exact x value from data (same as heatmapPaths cxs[xi]) + const xPx = u.valToPos(xs[xi * yBinQty], 'x', true); + const cx = Math.round(xPx - xSizePx / 2); + + if (yValue == null) { + // Full-height column fallback when Y value is unavailable + const cellKey = xi * -1 - 1; // negative keys = full-column + if (!drawnCells.has(cellKey)) { + drawnCells.add(cellKey); + u.ctx.fillRect(cx, u.bbox.top, xSizePx, u.bbox.height); } - } else { - columnMap.set(xLeft, { xLeft, minY: yValue, maxY: yValue }); + continue; } - } - u.ctx.save(); - u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); - u.ctx.clip(); + // Find which y bucket this value falls into + const yi = Math.max( + 0, + Math.min( + yBinQty - 1, + Math.round((yValue - ys[0]) / yBinIncr), + ), + ); + const cellKey = xi * (yBinQty + 1) + yi; + if (drawnCells.has(cellKey)) continue; + drawnCells.add(cellKey); - u.ctx.fillStyle = 'rgba(255, 220, 50, 0.6)'; + // Mirror heatmapPaths centering: cx = valToPos(x) - xSize/2 + // cy = valToPos(y) - ySizePx/2 + // ys[yi] is the y bucket start value for bin yi. + const yPx = u.valToPos(ys[yi], 'y', true); + const cy = Math.round(yPx - ySizePx / 2); - for (const { xLeft, minY, maxY } of columnMap.values()) { - if (minY == null || maxY == null) { - // No Y values available — highlight the full time column - u.ctx.fillRect(xLeft, u.bbox.top, xSizePx, u.bbox.height); - } else { - // Snap min/max to bin boundaries so rects align with heatmap cells. - // ys[0] is the first bin start value; bins increase by yBinIncr. - const minBinIdx = Math.max( - 0, - Math.round((minY - ys[0]) / yBinIncr), - ); - const maxBinIdx = Math.max( - 0, - Math.round((maxY - ys[0]) / yBinIncr), - ); - const minBinStart = ys[0] + minBinIdx * yBinIncr; - const maxBinEnd = ys[0] + (maxBinIdx + 1) * yBinIncr; - - // In canvas coords: larger Y value → smaller pixel y (inverted axis). - // Top of highest bin = valToPos(maxBinEnd), - // bottom of lowest bin = valToPos(minBinStart). - const topPx = u.valToPos(maxBinEnd, 'y', true); - const bottomPx = u.valToPos(minBinStart, 'y', true); - // Ensure at least one bin height is drawn - const heightPx = Math.max(ySizePx, bottomPx - topPx); - - u.ctx.fillRect(xLeft, topPx, xSizePx, heightPx); - } + u.ctx.fillRect(cx, cy, xSizePx, ySizePx); } u.ctx.restore(); @@ -1062,7 +1055,18 @@ function Heatmap({ selectingInfo.yMin, selectingInfo.yMax, ); - clearSelectionAndRect(); + // Clear the visual overlay only — do NOT call clearSelectionAndRect() + // because that would call onClearSelection which resets xMin/xMax/yMin/yMax + // to null, immediately undoing the filter we just applied. + setSelectingInfo(undefined); + if (uplotRef.current) { + try { + uplotRef.current.setSelect( + { left: 0, top: 0, width: 0, height: 0 }, + false, + ); + } catch (_) {} + } }} > Filter by Selection From d4d3da6b12de1b6a64bed694dd10f39792ec649d Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 03:16:23 +0000 Subject: [PATCH 17/43] fix: selection persists after Filter by Selection; only X clears it After clicking 'Filter by Selection', the selection rectangle now remains visible on the heatmap with the X button shown to clear it. Previously, the filter button immediately wiped the selection overlay. Changes: - Remove setSelectingInfo(undefined) from filter button handler - Remove container onClick that was dismissing overlay on chart clicks - setSelect guard for zero-size selections now returns early instead of clearing state (clearSelectionAndRect handles state explicitly) Co-authored-by: Alex Fedotyev --- .../app/src/components/DBHeatmapChart.tsx | 32 +++++-------------- 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index 1dfce67703..cf93c7b4b2 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -819,12 +819,11 @@ function Heatmap({ { hooks: { setSelect: u => { - // clearSelectionAndRect() calls u.setSelect({width:0,height:0}, false) to - // erase the visual rectangle. Even with fire=false, uPlot still fires this - // plugin hook. Guard against zero-size selections so the overlay doesn't - // reappear as a phantom after the user clears it. + // Ignore zero-size selections (single-click, or when clearSelectionAndRect() + // calls u.setSelect({width:0,height:0}) to erase the visual rectangle). + // clearSelectionAndRect() calls setSelectingInfo(undefined) directly before + // calling u.setSelect, so we don't need to do anything here. if (u.select.width <= 0 || u.select.height <= 0) { - setSelectingInfo(undefined); return; } @@ -837,8 +836,8 @@ function Heatmap({ 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 + // Small timeout to ensure this fires after uPlot completes its + // synchronous drag-end processing. setTimeout(() => { setSelectingInfo({ top: u.select.top + offsetTop, @@ -959,11 +958,6 @@ function Heatmap({
{ - if (selectingInfo != null) { - setSelectingInfo(undefined); - } - }} onMouseLeave={() => { setHighlightedPoint(undefined); }} @@ -1055,18 +1049,8 @@ function Heatmap({ selectingInfo.yMin, selectingInfo.yMax, ); - // Clear the visual overlay only — do NOT call clearSelectionAndRect() - // because that would call onClearSelection which resets xMin/xMax/yMin/yMax - // to null, immediately undoing the filter we just applied. - setSelectingInfo(undefined); - if (uplotRef.current) { - try { - uplotRef.current.setSelect( - { left: 0, top: 0, width: 0, height: 0 }, - false, - ); - } catch (_) {} - } + // Keep the selection rectangle visible after filtering so the user + // can see what area was selected. They can clear it with the X button. }} > Filter by Selection From 695788b6cbb4d4a1880bbf484e0f4e14e12a55f6 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 03:45:33 +0000 Subject: [PATCH 18/43] fix: highlight all spans, selection UX, and timeframe reset in Event Deltas - Highlight ALL matching spans (not just selection/outlier): combine outlier + inlier flat data so hover highlights show span positions across the full heatmap - Draw min-max Y range per X bucket for highlights: groups points by time column and draws a continuous rect from min to max duration, making highlights visible even when spans cluster at similar Y values (previously appeared 'at zero') - Hide 'Filter by Selection' button after it is clicked, leaving only X to clear - Reset heatmap selection (xMin/xMax/yMin/yMax) when the timeframe changes so the delta chart returns to distribution-only mode automatically Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBDeltaChart.tsx | 8 ++- .../app/src/components/DBHeatmapChart.tsx | 65 ++++++++++++------- .../Search/DBSearchHeatmapChart.tsx | 19 +++++- 3 files changed, 64 insertions(+), 28 deletions(-) diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index 585e99016f..38828673c9 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -1136,8 +1136,12 @@ export default function DBDeltaChart({ } }); - // Precompute flattened raw data for hover-based timestamp lookup - const flattenedRawData = actualOutlierData.map(flattenData); + // Precompute flattened raw data for hover-based timestamp lookup. + // Combine outlier + inlier so highlights show ALL matching spans (not just the selection subset). + const flattenedRawData = [ + ...actualOutlierData.map(flattenData), + ...actualInlierData.map(flattenData), + ]; return { outlierValueOccurences, diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index cf93c7b4b2..511235a6e0 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -697,12 +697,17 @@ function Heatmap({ | undefined >(undefined); + // After the user clicks "Filter by Selection", hide that button so only "X" remains. + // Resets to false when the selection is cleared. + const [hasFiltered, setHasFiltered] = useState(false); + // Refs for correlation highlight overlay: uPlot instance + latest highlight timestamps const uplotRef = useRef(null); // Clears the React selection state AND the uPlot selection rectangle const clearSelectionAndRect = useCallback(() => { setSelectingInfo(undefined); + setHasFiltered(false); if (uplotRef.current) { try { uplotRef.current.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false); @@ -897,11 +902,16 @@ function Heatmap({ u.ctx.clip(); u.ctx.fillStyle = 'rgba(255, 220, 50, 0.6)'; - // Deduplicate by (xi, yi) cell so we don't overdraw the same cell. - const drawnCells = new Set(); + // Group points by X bucket and compute min/max Y bucket per column. + // Drawing a continuous rect from min to max makes highlights visible + // even when all matching spans have similar Y values (would otherwise + // render as a single 1-cell sliver, hard to see). + const xBucketRange = new Map< + number, + { yiMin: number; yiMax: number; xPx: number; full: boolean } + >(); for (const { tsMs, yValue } of pts) { - // Find which x bucket this timestamp falls into const xi = Math.max( 0, Math.min( @@ -909,21 +919,14 @@ function Heatmap({ Math.round((tsMs - xs[0]) / xBinIncr), ), ); - // Use the exact x value from data (same as heatmapPaths cxs[xi]) const xPx = u.valToPos(xs[xi * yBinQty], 'x', true); - const cx = Math.round(xPx - xSizePx / 2); if (yValue == null) { - // Full-height column fallback when Y value is unavailable - const cellKey = xi * -1 - 1; // negative keys = full-column - if (!drawnCells.has(cellKey)) { - drawnCells.add(cellKey); - u.ctx.fillRect(cx, u.bbox.top, xSizePx, u.bbox.height); - } + // Mark this column as full-height + xBucketRange.set(xi, { yiMin: 0, yiMax: yBinQty - 1, xPx, full: true }); continue; } - // Find which y bucket this value falls into const yi = Math.max( 0, Math.min( @@ -931,17 +934,29 @@ function Heatmap({ Math.round((yValue - ys[0]) / yBinIncr), ), ); - const cellKey = xi * (yBinQty + 1) + yi; - if (drawnCells.has(cellKey)) continue; - drawnCells.add(cellKey); - // Mirror heatmapPaths centering: cx = valToPos(x) - xSize/2 - // cy = valToPos(y) - ySizePx/2 - // ys[yi] is the y bucket start value for bin yi. - const yPx = u.valToPos(ys[yi], 'y', true); - const cy = Math.round(yPx - ySizePx / 2); + const existing = xBucketRange.get(xi); + if (!existing) { + xBucketRange.set(xi, { yiMin: yi, yiMax: yi, xPx, full: false }); + } else if (!existing.full) { + existing.yiMin = Math.min(existing.yiMin, yi); + existing.yiMax = Math.max(existing.yiMax, yi); + } + } - u.ctx.fillRect(cx, cy, xSizePx, ySizePx); + for (const [, { yiMin, yiMax, xPx, full }] of xBucketRange) { + const cx = Math.round(xPx - xSizePx / 2); + if (full) { + u.ctx.fillRect(cx, u.bbox.top, xSizePx, u.bbox.height); + } else { + // Y axis is inverted: higher value = smaller pixel position (top). + // valToPos(ys[yiMax]) gives the top pixel, valToPos(ys[yiMin]) gives the bottom. + const topPx = u.valToPos(ys[yiMax], 'y', true); + const botPx = u.valToPos(ys[yiMin], 'y', true); + const cy = Math.round(topPx - ySizePx / 2); + const h = Math.round(botPx - topPx + ySizePx); + u.ctx.fillRect(cx, cy, xSizePx, Math.max(h, ySizePx)); + } } u.ctx.restore(); @@ -1036,7 +1051,7 @@ function Heatmap({ left: selectingInfo.left, }} > - {onFilter != null && ( + {onFilter != null && !hasFiltered && (
Filter by Selection @@ -1062,7 +1077,7 @@ function Heatmap({ title="Clear selection" style={{ cursor: 'pointer', - borderLeft: onFilter != null ? '1px solid #5F6776' : undefined, + borderLeft: onFilter != null && !hasFiltered ? '1px solid #5F6776' : undefined, }} onClick={e => { e.stopPropagation(); diff --git a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx index d7761b89da..2e1df083a9 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, useEffect, useRef, useState } from 'react'; import { parseAsFloat, parseAsString, useQueryStates } from 'nuqs'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -70,6 +70,23 @@ export function DBSearchHeatmapChart({ [onAddFilter, setFields], ); + // When the user changes the timeframe, reset any existing selection so the + // delta chart returns to distribution-only mode (no comparison). + const dateRangeRef = useRef(chartConfig.dateRange); + useEffect(() => { + const prev = dateRangeRef.current; + const next = chartConfig.dateRange; + if ( + prev[0].getTime() !== next[0].getTime() || + prev[1].getTime() !== next[1].getTime() + ) { + dateRangeRef.current = next; + setFields({ xMin: null, xMax: null, yMin: null, yMax: null }); + } else { + dateRangeRef.current = next; + } + }, [chartConfig.dateRange, setFields]); + return ( Date: Thu, 26 Feb 2026 15:59:35 +0000 Subject: [PATCH 19/43] fix: highlight all spans, selection UX, and timeframe reset in Event Deltas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch correlation highlight from min-to-max range to per-cell drawing. Previous approach: grouped matching spans by X time column, then drew a filled rectangle from yiMin to yiMax. Because even one fast span (near-zero duration) in a time column would pull yiMin to 0, highlights always extended to the bottom of the chart — even for attributes that only appear in slow spans. New approach: track each unique (xi, yi) cell position occupied by a matching span and draw one heatmap-sized cell per position. This means: - Attributes only in slow spans (e.g. error=true, 500ms+) show cells clustered near the top of the chart - Attributes in all spans (100% distribution) show cells distributed across all Y positions, accurately reflecting their spread across duration ranges - No false extension to yi=0 unless matching spans truly have near-zero duration The full-column fallback (yValue=null for complex expressions) is preserved: it now adds all Y cells for that X column individually rather than using min-to-max, keeping consistent with the per-cell visual style. Co-authored-by: Alex Fedotyev --- .../app/src/components/DBHeatmapChart.tsx | 57 +++++++++---------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index 511235a6e0..0f75222eed 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -902,14 +902,17 @@ function Heatmap({ u.ctx.clip(); u.ctx.fillStyle = 'rgba(255, 220, 50, 0.6)'; - // Group points by X bucket and compute min/max Y bucket per column. - // Drawing a continuous rect from min to max makes highlights visible - // even when all matching spans have similar Y values (would otherwise - // render as a single 1-cell sliver, hard to see). - const xBucketRange = new Map< - number, - { yiMin: number; yiMax: number; xPx: number; full: boolean } - >(); + // Draw one cell per unique (xi, yi) bucket position occupied by a + // matching span. This avoids the previous min-to-max range approach + // which always extended to yi=0 (the bottom of the chart) because any + // matching span with a near-zero duration pulled yiMin to 0 — even for + // attributes that only appear in slow spans. + // + // With per-cell drawing: if error=true only appears in 500ms+ spans, + // highlighted cells cluster near the top. If service=A appears in 100% + // of spans (all durations), cells are distributed across the full Y + // range, accurately reflecting the attribute's actual distribution. + const cellSet = new Set(); // encoded as xi * yBinQty + yi for (const { tsMs, yValue } of pts) { const xi = Math.max( @@ -919,11 +922,13 @@ function Heatmap({ Math.round((tsMs - xs[0]) / xBinIncr), ), ); - const xPx = u.valToPos(xs[xi * yBinQty], 'x', true); if (yValue == null) { - // Mark this column as full-height - xBucketRange.set(xi, { yiMin: 0, yiMax: yBinQty - 1, xPx, full: true }); + // Can't determine Y position (e.g. complex expression): draw all + // Y cells in this X column as fallback so there's visual feedback. + for (let yi = 0; yi < yBinQty; yi++) { + cellSet.add(xi * yBinQty + yi); + } continue; } @@ -934,29 +939,19 @@ function Heatmap({ Math.round((yValue - ys[0]) / yBinIncr), ), ); - - const existing = xBucketRange.get(xi); - if (!existing) { - xBucketRange.set(xi, { yiMin: yi, yiMax: yi, xPx, full: false }); - } else if (!existing.full) { - existing.yiMin = Math.min(existing.yiMin, yi); - existing.yiMax = Math.max(existing.yiMax, yi); - } + cellSet.add(xi * yBinQty + yi); } - for (const [, { yiMin, yiMax, xPx, full }] of xBucketRange) { + for (const cell of cellSet) { + const xi = Math.floor(cell / yBinQty); + const yi = cell % yBinQty; + const xPx = u.valToPos(xs[xi * yBinQty], 'x', true); const cx = Math.round(xPx - xSizePx / 2); - if (full) { - u.ctx.fillRect(cx, u.bbox.top, xSizePx, u.bbox.height); - } else { - // Y axis is inverted: higher value = smaller pixel position (top). - // valToPos(ys[yiMax]) gives the top pixel, valToPos(ys[yiMin]) gives the bottom. - const topPx = u.valToPos(ys[yiMax], 'y', true); - const botPx = u.valToPos(ys[yiMin], 'y', true); - const cy = Math.round(topPx - ySizePx / 2); - const h = Math.round(botPx - topPx + ySizePx); - u.ctx.fillRect(cx, cy, xSizePx, Math.max(h, ySizePx)); - } + // Y axis is inverted: higher value = smaller pixel position (top). + const cy = Math.round( + u.valToPos(ys[yi], 'y', true) - ySizePx / 2, + ); + u.ctx.fillRect(cx, cy, xSizePx, ySizePx); } u.ctx.restore(); From e067bb4d86246066fa0f177c3f5333ecc05d2d80 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:41:00 +0000 Subject: [PATCH 20/43] =?UTF-8?q?fix:=20address=20code=20review=20issues?= =?UTF-8?q?=20=E2=80=94=20SQL=20injection,=20file=20split,=20types,=20colo?= =?UTF-8?q?rs,=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Critical: escape single quotes in map keys in flattenedKeyToSqlExpression to prevent SQL injection (e.g. key "it's" → bracket notation "it''s") - High: split DBDeltaChart.tsx (1463 lines) into three focused files: - deltaChartUtils.ts — all pure utility functions and types - PropertyComparisonChart.tsx — chart component, TruncatedTick, tooltip - DBDeltaChart.tsx — main data-fetching + layout component (~710 lines) - Medium: reset copiedValue when clicking a new bar in the click popover - Medium: add loading state (text + legend hint) while attribute data fetches - Medium: replace hardcoded #339af0 / #868e96 with CSS variables (var(--mantine-color-blue-6) / var(--mantine-color-gray-5)) for theme support - Low: silent catch in clearSelectionAndRect → console.warn with context - Low: replace any types in TruncatedTick and HDXBarChartTooltip with proper typed interfaces using Recharts TooltipProps - Low: replace dateRangeRef/comparison pattern with idiomatic useEffect using primitive timestamp keys to avoid object-reference churn - Low: fix pre-existing invalid rgba(#hex ...) CSS in DBHeatmapChart tooltips → rgba(26, 29, 35, 0.75) / rgba(26, 29, 35, 0.4) (valid CSS) - Low: remove unused Container import; use Box in error render - Tests: update import paths from DBDeltaChart → deltaChartUtils, add two new test cases for single-quote escaping in SQL expressions Co-authored-by: Alex Fedotyev --- packages/app/src/components/DBDeltaChart.tsx | 891 ++---------------- .../app/src/components/DBHeatmapChart.tsx | 8 +- .../components/PropertyComparisonChart.tsx | 401 ++++++++ .../Search/DBSearchHeatmapChart.tsx | 23 +- .../components/__tests__/DBDeltaChart.test.ts | 14 +- .../app/src/components/deltaChartUtils.ts | 441 +++++++++ 6 files changed, 940 insertions(+), 838 deletions(-) create mode 100644 packages/app/src/components/PropertyComparisonChart.tsx create mode 100644 packages/app/src/components/deltaChartUtils.ts diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index 38828673c9..36037e53f9 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -1,15 +1,4 @@ -import { createPortal } from 'react-dom'; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { withErrorBoundary } from 'react-error-boundary'; -import { - Bar, - BarChart, - Cell, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse'; import { ChartConfigWithDateRange, @@ -19,38 +8,44 @@ import { import { Box, Code, - Container, Divider, Flex, Pagination, Text, } from '@mantine/core'; import { useElementSize } from '@mantine/hooks'; -import { - IconCopy, - IconFilter, - IconFilterX, -} from '@tabler/icons-react'; import { isAggregateFunction } from '@/ChartUtils'; import { useQueriedChartConfig } from '@/hooks/useChartConfig'; import { getFirstTimestampValueExpression } from '@/source'; -import { - getChartColorError, - getChartColorSuccess, - truncateMiddle, -} from '@/utils'; +import { getChartColorError, getChartColorSuccess } from '@/utils'; import { SQLPreview } from './ChartSQLPreview'; -import { DBRowTableIconButton } from './DBTable/DBRowTableIconButton'; - -import styles from '../../styles/HDXLineChart.module.scss'; - -function escapeRegExp(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function stripTypeWrappers(type: string): string { +import { + CHART_GAP, + CHART_HEIGHT, + CHART_WIDTH, + PAGINATION_HEIGHT, + PropertyComparisonChart, +} from './PropertyComparisonChart'; +import type { AddFilterFn, HighlightPoint } from './deltaChartUtils'; +import { + ALL_SPANS_COLOR, + computeDistributionScore, + computeYValue, + flattenData, + flattenedKeyToSqlExpression, + getPropertyStatistics, + isDenylisted, + isHighCardinality, + mergeValueStatisticsMaps, +} from './deltaChartUtils'; + +// Re-export types so callers importing from DBDeltaChart don't need to change. +export type { AddFilterFn, HighlightPoint } from './deltaChartUtils'; + +// Internal helper used only for timestamp column detection in this file. +function stripTypeWrappersLocal(type: string): string { let t = type.trim(); let changed = true; while (changed) { @@ -66,777 +61,6 @@ function stripTypeWrappers(type: string): string { return t; } -/** - * Converts a flattened dot-notation property key (produced by flattenData()) - * into a valid ClickHouse SQL expression for use in filter conditions. - * - * flattenData() uses JavaScript's object/array iteration, producing keys like: - * "ResourceAttributes.service.name" for Map(String, String) columns - * "Events.Attributes[0].message.type" for Array(Map(String, String)) columns - * - * These must be converted to bracket notation for ClickHouse Map access: - * "ResourceAttributes['service.name']" - * "Events.Attributes[1]['message.type']" (note: 0-based JS → 1-based CH index) - */ -export function flattenedKeyToSqlExpression( - key: string, - columnMeta: { name: string; type: string }[], -): string { - for (const col of columnMeta) { - const baseType = stripTypeWrappers(col.type); - - if (baseType.startsWith('Map(')) { - // Simple Map column: "MapCol.some.key" → "MapCol['some.key']" - if (key.startsWith(col.name + '.')) { - const mapKey = key.slice(col.name.length + 1); - return `${col.name}['${mapKey}']`; - } - } else if (baseType.startsWith('Array(')) { - const innerType = stripTypeWrappers(baseType.slice('Array('.length, -1)); - if (innerType.startsWith('Map(')) { - // Array(Map) column: "ColName[N].key" → "ColName[N+1]['key']" - // flattenData() uses 0-based JS indexing; ClickHouse SQL uses 1-based. - const pattern = new RegExp( - `^${escapeRegExp(col.name)}\\[(\\d+)\\]\\.(.+)$`, - ); - const match = key.match(pattern); - if (match) { - const chIndex = parseInt(match[1]) + 1; - const mapKey = match[2]; - return `${col.name}[${chIndex}]['${mapKey}']`; - } - } - } - } - return key; -} - -/** - * Returns true if the field is a structural ID field that should always be hidden. - * - * Matches: - * - Top-level String columns whose name ends in "Id" or "ID" (e.g., TraceId, SpanId) - * - Array(String) column elements or plain column references whose name ends in - * "Id" or "ID" (e.g., Links.TraceId[0] from a Links.TraceId Array(String) column) - */ -export function isIdField( - key: string, - columnMeta: { name: string; type: string }[], -): boolean { - // Extract base column name: - // "ColName[N]" → colName is "ColName" - // "ColName" (no brackets) → colName is the key itself - // "ColName[N].subkey" → has brackets but doesn't end with ], skip - const arrMatch = key.match(/^([^\[]+)\[(\d+)\]$/); - const colName = arrMatch ? arrMatch[1] : key.includes('[') ? null : key; - if (!colName) return false; - if (!/(Id|ID)$/.test(colName)) return false; - - const col = columnMeta.find(c => c.name === colName); - if (!col) return false; - const baseType = stripTypeWrappers(col.type); - if (baseType === 'String') return true; - if (baseType.startsWith('Array(')) { - const innerType = stripTypeWrappers(baseType.slice('Array('.length, -1)); - return innerType === 'String'; - } - return false; -} - -/** - * Returns true if the field is a per-index timestamp array element (e.g., - * Events.Timestamp[0]) from a column of type Array(DateTime64(...)), or the - * plain column reference itself (e.g., Events.Timestamp). - */ -export function isTimestampArrayField( - key: string, - columnMeta: { name: string; type: string }[], -): boolean { - const arrMatch = key.match(/^([^\[]+)\[(\d+)\]$/); - const colName = arrMatch ? arrMatch[1] : key.includes('[') ? null : key; - if (!colName) return false; - - const col = columnMeta.find(c => c.name === colName); - if (!col) return false; - const baseType = stripTypeWrappers(col.type); - if (!baseType.startsWith('Array(')) return false; - const innerType = stripTypeWrappers(baseType.slice('Array('.length, -1)); - return innerType.startsWith('DateTime64('); -} - -/** - * Returns true if the field should always be hidden per the structural denylist: - * - ID fields (TraceId, SpanId, ParentSpanId, Links.TraceId[N], Links.SpanId[N], etc.) - * - Per-index timestamp array elements (Events.Timestamp[N], Links.Timestamp[N], etc.) - */ -export function isDenylisted( - key: string, - columnMeta: { name: string; type: string }[], -): boolean { - return isIdField(key, columnMeta) || isTimestampArrayField(key, columnMeta); -} - -/** - * Returns true if the field should be hidden due to high cardinality (most values are - * unique, meaning it provides little analytical value in the comparison view). - * - * Takes the percentage occurrence maps (value → percentage 0–100) produced by - * getPropertyStatistics, and the raw property occurrence counts. Unique value count is - * derived from the map's size. - * - * A field is considered high cardinality when: - * min(outlierUniqueness, inlierUniqueness) > 0.9 AND combined sample size > 20 - * - * "min" ensures that if either group clusters (low cardinality), the field is kept visible. - * If only one group has data, that group's uniqueness alone is used. - */ -export function isHighCardinality( - key: string, - outlierValueOccurences: Map>, - inlierValueOccurences: Map>, - outlierPropertyOccurences: Map, - inlierPropertyOccurences: Map, -): boolean { - const outlierTotal = outlierPropertyOccurences.get(key) ?? 0; - const inlierTotal = inlierPropertyOccurences.get(key) ?? 0; - const combinedSampleSize = outlierTotal + inlierTotal; - if (combinedSampleSize <= 20) return false; - - const outlierUniqueValues = outlierValueOccurences.get(key)?.size ?? 0; - const inlierUniqueValues = inlierValueOccurences.get(key)?.size ?? 0; - - const outlierUniqueness = - outlierTotal > 0 ? outlierUniqueValues / outlierTotal : null; - const inlierUniqueness = - inlierTotal > 0 ? inlierUniqueValues / inlierTotal : null; - - let effectiveUniqueness: number; - if (outlierUniqueness !== null && inlierUniqueness !== null) { - effectiveUniqueness = Math.min(outlierUniqueness, inlierUniqueness); - } else if (outlierUniqueness !== null) { - effectiveUniqueness = outlierUniqueness; - } else if (inlierUniqueness !== null) { - effectiveUniqueness = inlierUniqueness; - } else { - return false; - } - - return effectiveUniqueness > 0.9; -} - -/* - * Response Data is like... -{ - Timestamp: "", - Map: { - "property": value, - } -} - -- Flatten -- Count Property Occurences -- Pick most common properties -- Count values for most common properties - -- Merge both sets of properties? one property? - */ - -// TODO: doesn't work for empty objects? -// https://stackoverflow.com/a/19101235 -function flattenData(data: Record) { - const result: Record = {}; - function recurse(cur: Record, prop: string) { - if (Object(cur) !== cur) { - result[prop] = cur; - } else if (Array.isArray(cur)) { - let l; - for (let i = 0, l = cur.length; i < l; i++) - recurse(cur[i], prop + '[' + i + ']'); - if (l == 0) result[prop] = []; - } else { - let isEmpty = true; - for (const p in cur) { - isEmpty = false; - recurse(cur[p], prop ? prop + '.' + p : p); - } - if (isEmpty && prop) result[prop] = {}; - } - } - recurse(data, ''); - return result; -} - -function getPropertyStatistics(data: Record[]) { - const flattened = data.map(flattenData); - const propertyOccurences = new Map(); - - const MIN_PROPERTY_OCCURENCES = 5; - const commonProperties = new Set(); - - flattened.forEach(item => { - Object.entries(item).forEach(([key, value]) => { - const count = propertyOccurences.get(key) || 0; - propertyOccurences.set(key, count + 1); - - if (count + 1 >= MIN_PROPERTY_OCCURENCES) { - commonProperties.add(key); - } - }); - }); - - // property -> (value -> count) - const valueOccurences = new Map>(); - flattened.forEach(item => { - Object.entries(item).forEach(([key, value]) => { - if (commonProperties.has(key)) { - let valuesMap = valueOccurences.get(key); - if (!valuesMap) { - valuesMap = new Map(); - valueOccurences.set(key, valuesMap); - } - - const valueCount = valuesMap.get(value) || 0; - valuesMap.set(value, valueCount + 1); - } - }); - }); - - // Divide by total rows so percentages represent "fraction of ALL spans", - // not "fraction of spans that have this property". This ensures a single-valued - // field that only appears in 30% of spans shows 30%, not 100%. - const totalRows = data.length || 1; - const percentageOccurences = new Map>(); - valueOccurences.forEach((valuesMap, property) => { - const percentageMap = new Map(); - valuesMap.forEach((valueCount, value) => { - percentageMap.set(value, (valueCount / totalRows) * 100); - }); - percentageOccurences.set(property, percentageMap); - }); - - return { - percentageOccurences, - propertyOccurences, - }; -} - -function mergeValueStatisticsMaps( - outlierValues: Map, // value -> count - inlierValues: Map, -) { - const mergedArray: { - name: string; - outlierCount: number; - inlierCount: number; - }[] = []; - // Collect all value names for this property - // we sort them so timestamps are ordered - const allValues = Array.from( - new Set([...outlierValues.keys(), ...inlierValues.keys()]), - ).sort(); - - allValues.forEach(value => { - const count1 = outlierValues.get(value) || 0; - const count2 = inlierValues.get(value) || 0; - mergedArray.push({ - name: value, - outlierCount: count1, - inlierCount: count2, - }); - }); - - return mergedArray; -} - -/** - * Computes a distribution skewness score for sorting properties in "all spans" mode. - * - * Score = max(pct) - (100 / n_values): - * - 0 for single-value fields (all spans share the same value → not useful for filtering) - * - 0 for perfectly uniform multi-value fields - * - High for skewed distributions where one value dominates others - * - * @param valuePercentages - Map from value string to its percentage (0–100) of occurrences - */ -export function computeDistributionScore( - valuePercentages: Map, -): number { - const nValues = valuePercentages.size; - if (nValues <= 1) return 0; - // Use mean(pcts) as the uniform baseline rather than 100/n so the score - // works correctly even when percentages don't sum to 100 (e.g., when - // each value's % is computed relative to ALL spans, not just spans with this property). - let totalPct = 0; - let maxPct = 0; - valuePercentages.forEach(pct => { - totalPct += pct; - if (pct > maxPct) maxPct = pct; - }); - if (totalPct === 0) return 0; - const uniformExpected = totalPct / nValues; - return maxPct - uniformExpected; -} - -export type AddFilterFn = ( - property: string, - value: string, - action?: 'only' | 'exclude' | 'include', -) => void; - -export type HighlightPoint = { tsMs: number; yValue: number | null }; - -/** - * Tries to compute the heatmap Y-axis value for a span from a raw flattened row. - * Handles simple SQL expressions: "ColName", "ColName / N", "ColName * N". - * Returns null if the expression is too complex or the column is missing. - */ -export function computeYValue( - valueExpr: string, - flatRow: Record, -): number | null { - const trimmed = valueExpr.trim(); - - // Identifier pattern (with optional surrounding parentheses): - // Matches "ColName" and "(ColName)" - const identPat = '\\(?([A-Za-z_][A-Za-z0-9_]*)\\)?'; - - // Simple column reference: "Duration" or "(Duration)" - const simpleMatch = trimmed.match(new RegExp(`^${identPat}$`)); - if (simpleMatch) { - const v = flatRow[simpleMatch[1]]; - if (v == null) return null; - const n = Number(v); - return isNaN(n) ? null : n; - } - - // Division: "Duration / 1000000", "(Duration)/1e6", "(Duration) / 1e6" - const numPat = '([0-9]+(?:\\.[0-9]+)?(?:e[+-]?[0-9]+)?)'; - const divMatch = trimmed.match( - new RegExp(`^${identPat}\\s*\\/\\s*${numPat}$`, 'i'), - ); - if (divMatch) { - const v = flatRow[divMatch[1]]; - if (v == null) return null; - const n = Number(v); - const d = parseFloat(divMatch[2]); - return isNaN(n) || isNaN(d) || d === 0 ? null : n / d; - } - - // Multiplication: "Duration * 0.001", "(Duration) * 0.001" - const mulMatch = trimmed.match( - new RegExp(`^${identPat}\\s*\\*\\s*${numPat}$`, 'i'), - ); - if (mulMatch) { - const v = flatRow[mulMatch[1]]; - if (v == null) return null; - const n = Number(v); - const m = parseFloat(mulMatch[2]); - return isNaN(n) || isNaN(m) ? null : n * m; - } - - return null; -} - -// Hover-only tooltip: shows value name and percentages, no action buttons. -// Actions are handled by the click popover in PropertyComparisonChart. -const HDXBarChartTooltip = withErrorBoundary( - memo((props: any) => { - const { active, payload, label, title } = props; - - if (active && payload && payload.length) { - return ( -
-
- {title && ( - - {title} - - )} - - {String(label).length === 0 ? Empty String : String(label)} - - {payload - .sort((a: any, b: any) => b.value - a.value) - .map((p: any) => ( -
- {p.name}: {p.value.toFixed(2)}% -
- ))} -
-
- ); - } - return null; - }), - { - onError: console.error, - fallback: ( -
- An error occurred while rendering the tooltip. -
- ), - }, -); - -// Custom XAxis tick that truncates long labels and adds a native SVG tooltip. -function TruncatedTick({ x, y, payload }: any) { - const value = String(payload?.value ?? ''); - const MAX_CHARS = 12; - const displayValue = - value.length > MAX_CHARS ? value.slice(0, MAX_CHARS) + '…' : value; - return ( - - {value} - - {displayValue} - - - ); -} - -// When a field has more than this many distinct values, the remaining values -// are collapsed into a single "Other (N)" bucket shown in neutral gray. -export const MAX_CHART_VALUES = 6; - -// Color for the "All spans" distribution bar (no selection / comparison mode off) -const ALL_SPANS_COLOR = '#339af0'; - -// Aggregates chart data beyond MAX_CHART_VALUES into a single "Other (N)" entry. -// Sorts by combined count (outlier + inlier) descending so the most frequent -// values are kept. Returns data unchanged if already within the limit. -export function applyTopNAggregation( - data: { name: string; outlierCount: number; inlierCount: number }[], -): { - name: string; - outlierCount: number; - inlierCount: number; - isOther?: boolean; -}[] { - if (data.length <= MAX_CHART_VALUES) return data; - - const sorted = [...data].sort( - (a, b) => - b.outlierCount + b.inlierCount - (a.outlierCount + a.inlierCount), - ); - const top = sorted.slice(0, MAX_CHART_VALUES); - const rest = sorted.slice(MAX_CHART_VALUES); - - const otherOutlierCount = rest.reduce((sum, item) => sum + item.outlierCount, 0); - const otherInlierCount = rest.reduce((sum, item) => sum + item.inlierCount, 0); - - return [ - ...top, - { - name: `Other (${rest.length})`, - outlierCount: otherOutlierCount, - inlierCount: otherInlierCount, - isOther: true, - }, - ]; -} - -function PropertyComparisonChart({ - name, - outlierValueOccurences, - inlierValueOccurences, - onAddFilter, - hasSelection, - onHoverValue, -}: { - name: string; - outlierValueOccurences: Map; - inlierValueOccurences: Map; - onAddFilter?: AddFilterFn; - hasSelection: boolean; - onHoverValue?: (property: string, value: string | null) => void; -}) { - const mergedValueStatistics = mergeValueStatisticsMaps( - outlierValueOccurences, - inlierValueOccurences, - ); - const chartData = applyTopNAggregation(mergedValueStatistics); - - const totalOutliers = useMemo( - () => - Array.from(outlierValueOccurences.values()).reduce((a, b) => a + b, 0), - [outlierValueOccurences], - ); - const totalInliers = useMemo( - () => - Array.from(inlierValueOccurences.values()).reduce((a, b) => a + b, 0), - [inlierValueOccurences], - ); - - const [clickedBar, setClickedBar] = useState<{ - value: string; - clientX: number; - clientY: number; - } | null>(null); - const [copiedValue, setCopiedValue] = useState(false); - // Local hover state for bar dimming — dims non-hovered bars for prominence - const [hoveredBar, setHoveredBar] = useState(null); - const popoverRef = useRef(null); - const chartWrapperRef = useRef(null); - // Track last hovered bar value to avoid firing onHoverValue on every pixel move - const lastHoveredBarRef = useRef(null); - - // Dismiss popover when clicking outside both the popover and the chart wrapper - useEffect(() => { - if (!clickedBar) return; - const handleClickOutside = (e: MouseEvent) => { - if ( - popoverRef.current && - !popoverRef.current.contains(e.target as Node) && - chartWrapperRef.current && - !chartWrapperRef.current.contains(e.target as Node) - ) { - setClickedBar(null); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [clickedBar]); - - // Dismiss popover on scroll (prevents stale popover when chart scrolls offscreen) - useEffect(() => { - if (!clickedBar) return; - const handleScroll = () => setClickedBar(null); - window.addEventListener('scroll', handleScroll, true); - return () => window.removeEventListener('scroll', handleScroll, true); - }, [clickedBar]); - - const handleChartClick = useCallback((data: any, event: any) => { - if (!data?.activePayload?.length) { - setClickedBar(null); - return; - } - if (data.activePayload[0]?.payload?.isOther) { - setClickedBar(null); - return; - } - setClickedBar({ - value: String(data.activeLabel ?? ''), - clientX: event.clientX, - clientY: event.clientY, - }); - }, []); - - return ( -
- - {truncateMiddle(name, 32)} - - - { - const label = data?.activeLabel; - const isOther = data?.activePayload?.[0]?.payload?.isOther; - const newVal = - label != null && !isOther ? String(label) : null; - if (newVal !== lastHoveredBarRef.current) { - lastHoveredBarRef.current = newVal; - onHoverValue?.(name, newVal); - setHoveredBar(newVal); - } - }} - onMouseLeave={() => { - if (lastHoveredBarRef.current !== null) { - lastHoveredBarRef.current = null; - onHoverValue?.(name, null); - } - setHoveredBar(null); - }} - > - } /> - - } - allowEscapeViewBox={{ x: true, y: true }} - wrapperStyle={{ zIndex: 9998 }} - /> - - {chartData.map((entry, index) => ( - - ))} - - {hasSelection && ( - - {chartData.map((entry, index) => ( - - ))} - - )} - - - {clickedBar && - createPortal( -
- - {truncateMiddle(name, 40)} - - - {clickedBar.value.length === 0 ? ( - Empty String - ) : ( - clickedBar.value - )} - - {hasSelection ? ( - - - Selection:{' '} - {(outlierValueOccurences.get(clickedBar.value) ?? 0).toFixed( - 1, - )} - % - - - Background:{' '} - {(inlierValueOccurences.get(clickedBar.value) ?? 0).toFixed( - 1, - )} - % - - - ) : ( - - Distribution:{' '} - {(outlierValueOccurences.get(clickedBar.value) ?? 0).toFixed(1)} - % - - )} - - {onAddFilter && ( - <> - { - onAddFilter(name, clickedBar.value, 'include'); - setClickedBar(null); - }} - > - - - { - onAddFilter(name, clickedBar.value, 'exclude'); - setClickedBar(null); - }} - > - - - - )} - { - try { - await navigator.clipboard.writeText(clickedBar.value); - setCopiedValue(true); - setTimeout(() => setCopiedValue(false), 2000); - } catch (err) { - console.error('Failed to copy:', err); - } - }} - > - - - -
, - document.body, - )} -
- ); -} - -// Layout constants for dynamic grid calculation. -// CHART_WIDTH is the minimum chart width used to determine how many columns fit; actual rendered -// width expands to fill the container (charts use width: '100%' inside a CSS grid). -// CHART_HEIGHT must match PropertyComparisonChart's outer div height. -// CHART_GAP is used both in the column/row formula and as the CSS grid gap. -const CHART_WIDTH = 340; // minimum column width threshold (px) -const CHART_HEIGHT = 120; // must match PropertyComparisonChart outer div height (px) -const CHART_GAP = 16; // px; used in grid gap and layout math -// Space reserved for the pagination row: Pagination control (~32px) + top padding (16px). -// Always reserved (even when pagination is hidden via visibility:hidden) so rows count is stable. -const PAGINATION_HEIGHT = 48; - export default function DBDeltaChart({ config, valueExpr, @@ -1001,7 +225,11 @@ export default function DBDeltaChart({ ]; }; - const { data: outlierData, error: outlierError } = useQueriedChartConfig( + const { + data: outlierData, + error: outlierError, + isLoading: isOutlierLoading, + } = useQueriedChartConfig( { ...config, with: buildWithClauses(true), @@ -1013,20 +241,25 @@ export default function DBDeltaChart({ { enabled: hasSelection }, ); - const { data: inlierData } = useQueriedChartConfig( - { - ...config, - with: buildWithClauses(false), - select: '*', - filters: buildFilters(false), - orderBy: [{ ordering: 'DESC', valueExpression: 'rand()' }], - limit: { limit: 1000 }, - }, - { enabled: hasSelection }, - ); + const { data: inlierData, isLoading: isInlierLoading } = + useQueriedChartConfig( + { + ...config, + with: buildWithClauses(false), + select: '*', + filters: buildFilters(false), + orderBy: [{ ordering: 'DESC', valueExpression: 'rand()' }], + limit: { limit: 1000 }, + }, + { enabled: hasSelection }, + ); // When no selection exists, fetch all spans without any range filter - const { data: allSpansData, error: allSpansError } = useQueriedChartConfig( + const { + data: allSpansData, + error: allSpansError, + isLoading: isAllSpansLoading, + } = useQueriedChartConfig( { ...config, select: '*', @@ -1036,6 +269,10 @@ export default function DBDeltaChart({ { enabled: !hasSelection }, ); + const isLoading = hasSelection + ? isOutlierLoading || isInlierLoading + : isAllSpansLoading; + const error = outlierError ?? allSpansError; // Compute column metadata, property statistics, sorted/visible/hidden property lists. @@ -1085,7 +322,7 @@ export default function DBDeltaChart({ // Sort properties by how useful they are for filtering/analysis. // Comparison mode: sort by max difference between selection and background. // Distribution mode (no selection): sort by deviation from uniform distribution — - // score = max(pct) - (100 / n_values) + // score = max(pct) - mean(pcts) // This scores 0 for single-value fields (all-same) and for perfectly uniform // multi-value fields, and scores high for skewed distributions. const sortedProperties = Array.from(uniqueKeys) @@ -1189,9 +426,9 @@ export default function DBDeltaChart({ // Find the first non-array DateTime64 column (typically 'Timestamp') const tsColName = columnMeta.find( c => - (stripTypeWrappers(c.type).startsWith('DateTime64(') || + (stripTypeWrappersLocal(c.type).startsWith('DateTime64(') || c.type === 'DateTime64') && - !stripTypeWrappers(c.type).startsWith('Array('), + !stripTypeWrappersLocal(c.type).startsWith('Array('), )?.name; if (!tsColName) return null; @@ -1243,7 +480,7 @@ export default function DBDeltaChart({ if (error) { return ( - + Error Message: @@ -1272,7 +509,7 @@ export default function DBDeltaChart({ )} - + ); } @@ -1371,11 +608,21 @@ export default function DBDeltaChart({
- Select an area on the chart above to enable comparisons + {isLoading + ? 'Loading…' + : 'Select an area on the chart above to enable comparisons'} )} + {/* Loading state */} + {isLoading && visibleOnPage.length === 0 && hiddenOnPage.length === 0 && ( + + + Loading attribute distributions… + + + )} {/* Primary fields — own grid so empty trailing cells don't interact with divider */} {visibleOnPage.length > 0 && (