Skip to content

Commit daffcf3

Browse files
authored
feat: Add percentages to filter values (#1250)
# Summary Closes HDX-1960 This PR adds a button to our search filters component which can be used to show the _approximate_ percentage of rows which have each filter value. https://github.com/user-attachments/assets/2dba1b28-d2b9-4414-986c-0c515d252c89 Notes: - The percentages are based on a sample of 100k rows. The sampling is done similarly to how EE version samples logs for patterns. - We only fetch the most common 100 values in the sample. All other values are assumed to represent <1% of the data. - The percentages represent the distribution within the dataset after it has been filtered by the selected filters and the where clause. - This is a potentially expensive query, even with sampling, so the percentages are only queried if they're toggled on for a particular filter, and do not refresh in live mode. They do refresh if the search or date ranges changes (outside of live mode).
1 parent 13b191c commit daffcf3

File tree

8 files changed

+480
-17
lines changed

8 files changed

+480
-17
lines changed

.changeset/tricky-brooms-thank.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@hyperdx/common-utils": patch
3+
"@hyperdx/app": patch
4+
---
5+
6+
feat: Add percentages to filter values

packages/app/src/DBSearchPage.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ import {
5555
useDocumentVisibility,
5656
} from '@mantine/hooks';
5757
import { notifications } from '@mantine/notifications';
58-
import { useIsFetching } from '@tanstack/react-query';
58+
import { keepPreviousData, useIsFetching } from '@tanstack/react-query';
5959
import { SortingState } from '@tanstack/react-table';
6060
import CodeMirror from '@uiw/react-codemirror';
6161

@@ -1099,7 +1099,10 @@ function DBSearchPage() {
10991099
}
11001100
}, [isReady, queryReady, isChartConfigLoading, onSearch]);
11011101

1102-
const { data: aliasMap } = useAliasMapFromChartConfig(dbSqlRowTableConfig);
1102+
const { data: aliasMap } = useAliasMapFromChartConfig(dbSqlRowTableConfig, {
1103+
placeholderData: keepPreviousData,
1104+
queryKey: ['aliasMap', dbSqlRowTableConfig, 'withPlaceholder'],
1105+
});
11031106

11041107
const aliasWith = useMemo(
11051108
() =>

packages/app/src/components/DBSearchPageFilters.tsx

Lines changed: 131 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { useExplainQuery } from '@/hooks/useExplainQuery';
4040
import {
4141
useAllFields,
4242
useGetKeyValues,
43+
useGetValuesDistribution,
4344
useJsonColumns,
4445
useTableMetadata,
4546
} from '@/hooks/useMetadata';
@@ -76,6 +77,8 @@ type FilterCheckboxProps = {
7677
onClickExclude?: VoidFunction;
7778
onClickPin: VoidFunction;
7879
className?: string;
80+
percentage?: number;
81+
isPercentageLoading?: boolean;
7982
};
8083

8184
export const TextButton = ({
@@ -105,6 +108,26 @@ export const TextButton = ({
105108
);
106109
};
107110

111+
type FilterPercentageProps = {
112+
percentage: number;
113+
isLoading?: boolean;
114+
};
115+
116+
const FilterPercentage = ({ percentage, isLoading }: FilterPercentageProps) => {
117+
const formattedPercentage =
118+
percentage < 1
119+
? `<1%`
120+
: percentage >= 99.5
121+
? `>99%`
122+
: `~${Math.round(percentage)}%`;
123+
124+
return (
125+
<Text size="xs" c="gray.3" className={isLoading ? 'effect-pulse' : ''}>
126+
{formattedPercentage}
127+
</Text>
128+
);
129+
};
130+
108131
const emptyFn = () => {};
109132
export const FilterCheckbox = ({
110133
value,
@@ -115,6 +138,8 @@ export const FilterCheckbox = ({
115138
onClickExclude,
116139
onClickPin,
117140
className,
141+
percentage,
142+
isPercentageLoading,
118143
}: FilterCheckboxProps) => {
119144
return (
120145
<div
@@ -146,15 +171,30 @@ export const FilterCheckbox = ({
146171
fz="xxs"
147172
color="gray"
148173
>
149-
<Text
150-
size="xs"
151-
c={value === 'excluded' ? 'red.4' : 'gray.3'}
152-
truncate="end"
174+
<Group
153175
w="100%"
154-
title={label}
176+
gap="xs"
177+
wrap="nowrap"
178+
justify="space-between"
179+
pe={'11px'}
180+
miw={0}
155181
>
156-
{label}
157-
</Text>
182+
<Text
183+
size="xs"
184+
c={value === 'excluded' ? 'red.4' : 'gray.3'}
185+
truncate="end"
186+
flex={1}
187+
title={label}
188+
>
189+
{label}
190+
</Text>
191+
{percentage != null && (
192+
<FilterPercentage
193+
percentage={percentage}
194+
isLoading={isPercentageLoading}
195+
/>
196+
)}
197+
</Group>
158198
</Tooltip>
159199
</Group>
160200
<div className={classes.filterActions}>
@@ -208,6 +248,8 @@ export type FilterGroupProps = {
208248
hasLoadedMore: boolean;
209249
isDefaultExpanded?: boolean;
210250
'data-testid'?: string;
251+
chartConfig: ChartConfigWithDateRange;
252+
isLive?: boolean;
211253
};
212254

213255
const MAX_FILTER_GROUP_ITEMS = 10;
@@ -230,6 +272,8 @@ export const FilterGroup = ({
230272
hasLoadedMore,
231273
isDefaultExpanded,
232274
'data-testid': dataTestId,
275+
chartConfig,
276+
isLive,
233277
}: FilterGroupProps) => {
234278
const [search, setSearch] = useState('');
235279
// "Show More" button when there's lots of options
@@ -238,13 +282,60 @@ export const FilterGroup = ({
238282
const [isExpanded, setExpanded] = useState(isDefaultExpanded ?? false);
239283
// Track recently moved items for highlight animation
240284
const [recentlyMoved, setRecentlyMoved] = useState<Set<string>>(new Set());
285+
// Show what percentage of the data has each value
286+
const [showDistributions, setShowDistributions] = useState(false);
287+
// For live searches, don't refresh percentages when date range changes
288+
const [dateRange, setDateRange] = useState<[Date, Date]>(
289+
chartConfig.dateRange,
290+
);
291+
292+
const toggleShowDistributions = () => {
293+
if (!showDistributions) {
294+
setExpanded(true);
295+
setDateRange(chartConfig.dateRange);
296+
}
297+
setShowDistributions(prev => !prev);
298+
};
299+
300+
useEffect(() => {
301+
if (!isLive) {
302+
setDateRange(chartConfig.dateRange);
303+
}
304+
}, [chartConfig.dateRange, isLive]);
241305

242306
useEffect(() => {
243307
if (isDefaultExpanded) {
244308
setExpanded(true);
245309
}
246310
}, [isDefaultExpanded]);
247311

312+
const {
313+
data: distributionData,
314+
isFetching: isFetchingDistribution,
315+
error: distributionError,
316+
} = useGetValuesDistribution(
317+
{
318+
chartConfig: { ...chartConfig, dateRange },
319+
key: name,
320+
limit: 100, // The 100 most common values are enough to find any values that are present in at least 1% of rows
321+
},
322+
{
323+
enabled: showDistributions,
324+
},
325+
);
326+
327+
useEffect(() => {
328+
if (distributionError) {
329+
notifications.show({
330+
color: 'red',
331+
title: 'Error loading filter distribution',
332+
message: distributionError?.message,
333+
autoClose: 5000,
334+
});
335+
setShowDistributions(false);
336+
}
337+
}, [distributionError]);
338+
248339
const totalFiltersSize =
249340
selectedValues.included.size + selectedValues.excluded.size;
250341

@@ -292,6 +383,13 @@ export const FilterGroup = ({
292383
if (aExcluded && !bExcluded) return -1;
293384
if (!aExcluded && bExcluded) return 1;
294385

386+
// Then sort by estimated percentage of rows with this value, if available
387+
const aPercentage = distributionData?.get(a.value) ?? 0;
388+
const bPercentage = distributionData?.get(b.value) ?? 0;
389+
if (aPercentage !== bPercentage) {
390+
return bPercentage - aPercentage;
391+
}
392+
295393
// Finally sort alphabetically/numerically
296394
return a.value.localeCompare(b.value, undefined, { numeric: true });
297395
});
@@ -310,6 +408,7 @@ export const FilterGroup = ({
310408
augmentedOptions,
311409
selectedValues,
312410
totalFiltersSize,
411+
distributionData,
313412
]);
314413

315414
// Simple highlight animation when checkbox is checked
@@ -402,13 +501,30 @@ export const FilterGroup = ({
402501
</Tooltip>
403502
</Accordion.Control>
404503
<Group gap="xxxs" wrap="nowrap">
504+
<ActionIcon
505+
size="xs"
506+
variant="subtle"
507+
color="gray"
508+
onClick={toggleShowDistributions}
509+
title={
510+
showDistributions ? 'Hide distribution' : 'Show distribution'
511+
}
512+
data-testid={`toggle-distribution-button-${name}`}
513+
aria-checked={showDistributions}
514+
role="checkbox"
515+
>
516+
<i
517+
className={`bi ${isFetchingDistribution ? 'spinner-border spinner-border-sm' : showDistributions ? 'bi-bar-chart-line-fill' : 'bi-bar-chart-line'}`}
518+
/>
519+
</ActionIcon>
405520
{onFieldPinClick && (
406521
<ActionIcon
407522
size="xs"
408523
variant="subtle"
409524
color="gray"
410525
onClick={onFieldPinClick}
411526
title={isFieldPinned ? 'Unpin field' : 'Pin field'}
527+
me={'4px'}
412528
>
413529
<i
414530
className={`bi bi-pin-angle${isFieldPinned ? '-fill' : ''}`}
@@ -452,6 +568,12 @@ export const FilterGroup = ({
452568
onClickOnly={() => onOnlyClick(option.value)}
453569
onClickExclude={() => onExcludeClick(option.value)}
454570
onClickPin={() => onPinClick(option.value)}
571+
isPercentageLoading={isFetchingDistribution}
572+
percentage={
573+
showDistributions && distributionData
574+
? (distributionData.get(option.value) ?? 0)
575+
: undefined
576+
}
455577
/>
456578
))}
457579
{optionsLoading ? (
@@ -900,6 +1022,8 @@ const DBSearchPageFiltersComponent = ({
9001022
(filterState[facet.key].included.size > 0 ||
9011023
filterState[facet.key].excluded.size > 0))
9021024
}
1025+
chartConfig={chartConfig}
1026+
isLive={isLive}
9031027
/>
9041028
))}
9051029

0 commit comments

Comments
 (0)