Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/filter-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---

feat: filter/exclude/copy actions on Event Deltas attribute values
1 change: 1 addition & 0 deletions packages/app/src/DBSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1870,6 +1870,7 @@ function DBSearchPage() {
}}
isReady={isReady}
source={searchedSource}
onAddFilter={searchFilters.setFilterValue}
/>
)}
{analysisMode === 'results' && (
Expand Down
20 changes: 19 additions & 1 deletion packages/app/src/components/DBDeltaChart.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
import {
ChartConfigWithDateRange,
Expand All @@ -21,14 +21,19 @@ import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { getFirstTimestampValueExpression } from '@/source';

import { SQLPreview } from './ChartSQLPreview';
import type { AddFilterFn } from './deltaChartUtils';
import {
flattenedKeyToFilterKey,
getPropertyStatistics,
getStableSampleExpression,
isDenylisted,
isHighCardinality,
mergeValueStatisticsMaps,
SAMPLE_SIZE,
} from './deltaChartUtils';

// Re-export types so callers importing from DBDeltaChart don't need to change.
export type { AddFilterFn } from './deltaChartUtils';
import {
CHART_GAP,
CHART_HEIGHT,
Expand All @@ -44,6 +49,7 @@ export default function DBDeltaChart({
xMax,
yMin,
yMax,
onAddFilter,
spanIdExpression,
}: {
config: ChartConfigWithDateRange;
Expand All @@ -52,6 +58,7 @@ export default function DBDeltaChart({
xMax: number;
yMin: number;
yMax: number;
onAddFilter?: AddFilterFn;
spanIdExpression?: string;
}) {
// Determine if the value expression uses aggregate functions
Expand Down Expand Up @@ -217,6 +224,15 @@ export default function DBDeltaChart({
[outlierData?.meta, inlierData?.meta],
);

// Wrap onAddFilter to convert flattened dot-notation keys into ClickHouse bracket notation
const handleAddFilter = useCallback<NonNullable<AddFilterFn>>(
(property, value, action) => {
if (!onAddFilter) return;
onAddFilter(flattenedKeyToFilterKey(property, columnMeta), value, action);
},
[onAddFilter, columnMeta],
);

// TODO: Is loading state
const {
visibleProperties,
Expand Down Expand Up @@ -412,6 +428,7 @@ export default function DBDeltaChart({
inlierValueOccurences={
inlierValueOccurences.get(property) ?? new Map()
}
onAddFilter={onAddFilter ? handleAddFilter : undefined}
key={property}
/>
))}
Expand Down Expand Up @@ -448,6 +465,7 @@ export default function DBDeltaChart({
inlierValueOccurences={
inlierValueOccurences.get(key) ?? new Map()
}
onAddFilter={onAddFilter ? handleAddFilter : undefined}
key={key}
/>
))}
Expand Down
189 changes: 184 additions & 5 deletions packages/app/src/components/PropertyComparisonChart.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { memo } from 'react';
import { memo, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { withErrorBoundary } from 'react-error-boundary';
import type { TooltipProps } from 'recharts';
import {
Expand All @@ -10,10 +11,17 @@ import {
XAxis,
YAxis,
} from 'recharts';
import { Text } from '@mantine/core';
import { Flex, Text } from '@mantine/core';
import { IconCopy, IconFilter, IconFilterX } from '@tabler/icons-react';

import { getChartColorError, getChartColorSuccess } from '@/utils';
import {
getChartColorError,
getChartColorSuccess,
truncateMiddle,
} from '@/utils';

import { DBRowTableIconButton } from './DBTable/DBRowTableIconButton';
import type { AddFilterFn } from './deltaChartUtils';
import {
applyTopNAggregation,
mergeValueStatisticsMaps,
Expand Down Expand Up @@ -46,6 +54,7 @@ type TooltipContentProps = TooltipProps<number, string> & {
};

// Hover-only tooltip: shows value name and percentages.
// Actions are handled by the click popover in PropertyComparisonChart.
const HDXBarChartTooltip = withErrorBoundary(
memo(({ active, payload, label, title }: TooltipContentProps) => {
if (active && payload && payload.length) {
Expand Down Expand Up @@ -94,7 +103,7 @@ function TruncatedTick({ x = 0, y = 0, payload }: TickProps) {
const value = String(payload?.value ?? '');
const MAX_CHARS = 12;
const displayValue =
value.length > MAX_CHARS ? value.slice(0, MAX_CHARS) + '' : value;
value.length > MAX_CHARS ? value.slice(0, MAX_CHARS) + '\u2026' : value;
return (
<g transform={`translate(${x},${y})`}>
<title>{value}</title>
Expand All @@ -117,17 +126,78 @@ export function PropertyComparisonChart({
name,
outlierValueOccurences,
inlierValueOccurences,
onAddFilter,
}: {
name: string;
outlierValueOccurences: Map<string, number>;
inlierValueOccurences: Map<string, number>;
onAddFilter?: AddFilterFn;
}) {
const mergedValueStatistics = mergeValueStatisticsMaps(
outlierValueOccurences,
inlierValueOccurences,
);
const chartData = applyTopNAggregation(mergedValueStatistics);

const [clickedBar, setClickedBar] = useState<{
value: string;
clientX: number;
clientY: number;
} | null>(null);
const [copiedValue, setCopiedValue] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Clean up copy confirmation timeout on unmount
useEffect(() => {
return () => {
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
};
}, []);

// Dismiss popover on click outside, scroll, or Escape key
useEffect(() => {
if (!clickedBar) return;
const handleClickOutside = (e: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(e.target as Node)
) {
setClickedBar(null);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setClickedBar(null);
};
const handleScroll = () => setClickedBar(null);
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
window.addEventListener('scroll', handleScroll, true);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('scroll', handleScroll, true);
};
}, [clickedBar]);

const handleChartClick = (data: any, event: any) => {
if (!data?.activePayload?.length) {
setClickedBar(null);
return;
}
if (data.activePayload[0]?.payload?.isOther) {
setClickedBar(null);
return;
}
// Reset copy confirmation so it doesn't carry over to the new bar's popover
setCopiedValue(false);
setClickedBar({
value: String(data.activeLabel ?? ''),
clientX: event.clientX,
clientY: event.clientY,
});
};

return (
<div style={{ width: '100%', height: CHART_HEIGHT }}>
<Text
Expand All @@ -154,14 +224,16 @@ export function PropertyComparisonChart({
left: 0,
bottom: 0,
}}
onClick={handleChartClick}
style={{ cursor: 'pointer' }}
>
<XAxis dataKey="name" tick={<TruncatedTick />} />
<YAxis
tick={{ fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' }}
/>
<Tooltip
content={<HDXBarChartTooltip title={name} />}
allowEscapeViewBox={{ x: true, y: true }}
allowEscapeViewBox={{ x: false, y: true }}
wrapperStyle={{ zIndex: 1000 }}
/>
<Bar
Expand Down Expand Up @@ -194,6 +266,113 @@ export function PropertyComparisonChart({
</Bar>
</BarChart>
</ResponsiveContainer>
{clickedBar &&
createPortal(
<div
ref={popoverRef}
className={styles.chartTooltip}
style={{
position: 'fixed',
// Clamp horizontally so the popover stays within the viewport
left: Math.max(
160,
Math.min(clickedBar.clientX, window.innerWidth - 160),
),
// Render above click point; flip below if near the top
top:
clickedBar.clientY > 200
? clickedBar.clientY - 8
: clickedBar.clientY + 16,
transform:
clickedBar.clientY > 200
? 'translate(-50%, -100%)'
: 'translate(-50%, 0)',
zIndex: 1000,
borderRadius: 4,
padding: '8px 12px',
minWidth: 200,
maxWidth: 320,
boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
}}
>
<Text
size="xs"
c="dimmed"
fw={600}
mb={4}
style={{ wordBreak: 'break-all' }}
title={name}
>
{truncateMiddle(name, 40)}
</Text>
<Text size="xs" mb={6} style={{ wordBreak: 'break-all' }}>
{clickedBar.value.length === 0 ? (
<i>Empty String</i>
) : (
clickedBar.value
)}
</Text>
<Flex gap={12} mb={8}>
<Text size="xs" c={getChartColorError()}>
Selection:{' '}
{(outlierValueOccurences.get(clickedBar.value) ?? 0).toFixed(1)}
%
</Text>
<Text size="xs" c={getChartColorSuccess()}>
Background:{' '}
{(inlierValueOccurences.get(clickedBar.value) ?? 0).toFixed(1)}%
</Text>
</Flex>
<Flex gap={4} align="center">
{onAddFilter && (
<>
<DBRowTableIconButton
variant="copy"
title="Filter for this value"
onClick={() => {
onAddFilter(name, clickedBar.value, 'include');
setClickedBar(null);
}}
>
<IconFilter size={12} />
</DBRowTableIconButton>
<DBRowTableIconButton
variant="copy"
title="Exclude this value"
onClick={() => {
onAddFilter(name, clickedBar.value, 'exclude');
setClickedBar(null);
}}
>
<IconFilterX size={12} />
</DBRowTableIconButton>
</>
)}
<DBRowTableIconButton
variant="copy"
title={copiedValue ? 'Copied!' : 'Copy value'}
isActive={copiedValue}
onClick={async () => {
try {
await navigator.clipboard.writeText(clickedBar.value);
setCopiedValue(true);
if (copyTimeoutRef.current)
clearTimeout(copyTimeoutRef.current);
copyTimeoutRef.current = setTimeout(
() => setCopiedValue(false),
2000,
);
} catch (err) {
console.error('Failed to copy:', err);
}
}}
>
<IconCopy size={12} />
</DBRowTableIconButton>
</Flex>
</div>,
document.body,
)}
</div>
);
}
20 changes: 19 additions & 1 deletion packages/app/src/components/Search/DBSearchHeatmapChart.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useState } from 'react';
import { parseAsFloat, parseAsString, useQueryStates } from 'nuqs';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
Expand All @@ -21,6 +21,7 @@ import { IconPlayerPlay } from '@tabler/icons-react';
import { SQLInlineEditorControlled } from '@/components/SearchInput/SQLInlineEditor';
import { getDurationMsExpression } from '@/source';

import type { AddFilterFn } from '../DBDeltaChart';
import DBDeltaChart from '../DBDeltaChart';
import DBHeatmapChart from '../DBHeatmapChart';

Expand All @@ -33,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)),
Expand All @@ -49,6 +52,18 @@ export function DBSearchHeatmapChart({
});
const [container, setContainer] = useState<HTMLElement | null>(null);

// After applying a filter, clear the heatmap selection so the delta chart
// resets instead of staying in comparison mode.
const handleAddFilterAndClearSelection = useCallback<
NonNullable<AddFilterFn>
>(
(property, value, action) => {
setFields({ xMin: null, xMax: null, yMin: null, yMax: null });
onAddFilter?.(property, value, action);
},
[onAddFilter, setFields],
);

return (
<Flex
direction="column"
Expand Down Expand Up @@ -119,6 +134,9 @@ export function DBSearchHeatmapChart({
xMax={fields.xMax}
yMin={fields.yMin}
yMax={fields.yMax}
onAddFilter={
onAddFilter ? handleAddFilterAndClearSelection : undefined
}
spanIdExpression={source.spanIdExpression}
/>
) : (
Expand Down
Loading
Loading