diff --git a/.changeset/tricky-brooms-thank.md b/.changeset/tricky-brooms-thank.md
new file mode 100644
index 0000000000..8f1c8b3412
--- /dev/null
+++ b/.changeset/tricky-brooms-thank.md
@@ -0,0 +1,6 @@
+---
+"@hyperdx/common-utils": patch
+"@hyperdx/app": patch
+---
+
+feat: Add percentages to filter values
diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx
index 73f7067159..e58a4a100e 100644
--- a/packages/app/src/DBSearchPage.tsx
+++ b/packages/app/src/DBSearchPage.tsx
@@ -55,7 +55,7 @@ import {
useDocumentVisibility,
} from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
-import { useIsFetching } from '@tanstack/react-query';
+import { keepPreviousData, useIsFetching } from '@tanstack/react-query';
import { SortingState } from '@tanstack/react-table';
import CodeMirror from '@uiw/react-codemirror';
@@ -1099,7 +1099,10 @@ function DBSearchPage() {
}
}, [isReady, queryReady, isChartConfigLoading, onSearch]);
- const { data: aliasMap } = useAliasMapFromChartConfig(dbSqlRowTableConfig);
+ const { data: aliasMap } = useAliasMapFromChartConfig(dbSqlRowTableConfig, {
+ placeholderData: keepPreviousData,
+ queryKey: ['aliasMap', dbSqlRowTableConfig, 'withPlaceholder'],
+ });
const aliasWith = useMemo(
() =>
diff --git a/packages/app/src/components/DBSearchPageFilters.tsx b/packages/app/src/components/DBSearchPageFilters.tsx
index b737c19945..615913420d 100644
--- a/packages/app/src/components/DBSearchPageFilters.tsx
+++ b/packages/app/src/components/DBSearchPageFilters.tsx
@@ -40,6 +40,7 @@ import { useExplainQuery } from '@/hooks/useExplainQuery';
import {
useAllFields,
useGetKeyValues,
+ useGetValuesDistribution,
useJsonColumns,
useTableMetadata,
} from '@/hooks/useMetadata';
@@ -76,6 +77,8 @@ type FilterCheckboxProps = {
onClickExclude?: VoidFunction;
onClickPin: VoidFunction;
className?: string;
+ percentage?: number;
+ isPercentageLoading?: boolean;
};
export const TextButton = ({
@@ -105,6 +108,26 @@ export const TextButton = ({
);
};
+type FilterPercentageProps = {
+ percentage: number;
+ isLoading?: boolean;
+};
+
+const FilterPercentage = ({ percentage, isLoading }: FilterPercentageProps) => {
+ const formattedPercentage =
+ percentage < 1
+ ? `<1%`
+ : percentage >= 99.5
+ ? `>99%`
+ : `~${Math.round(percentage)}%`;
+
+ return (
+
+ {formattedPercentage}
+
+ );
+};
+
const emptyFn = () => {};
export const FilterCheckbox = ({
value,
@@ -115,6 +138,8 @@ export const FilterCheckbox = ({
onClickExclude,
onClickPin,
className,
+ percentage,
+ isPercentageLoading,
}: FilterCheckboxProps) => {
return (
-
- {label}
-
+
+ {label}
+
+ {percentage != null && (
+
+ )}
+
@@ -208,6 +248,8 @@ export type FilterGroupProps = {
hasLoadedMore: boolean;
isDefaultExpanded?: boolean;
'data-testid'?: string;
+ chartConfig: ChartConfigWithDateRange;
+ isLive?: boolean;
};
const MAX_FILTER_GROUP_ITEMS = 10;
@@ -230,6 +272,8 @@ export const FilterGroup = ({
hasLoadedMore,
isDefaultExpanded,
'data-testid': dataTestId,
+ chartConfig,
+ isLive,
}: FilterGroupProps) => {
const [search, setSearch] = useState('');
// "Show More" button when there's lots of options
@@ -238,6 +282,26 @@ export const FilterGroup = ({
const [isExpanded, setExpanded] = useState(isDefaultExpanded ?? false);
// Track recently moved items for highlight animation
const [recentlyMoved, setRecentlyMoved] = useState
>(new Set());
+ // Show what percentage of the data has each value
+ const [showDistributions, setShowDistributions] = useState(false);
+ // For live searches, don't refresh percentages when date range changes
+ const [dateRange, setDateRange] = useState<[Date, Date]>(
+ chartConfig.dateRange,
+ );
+
+ const toggleShowDistributions = () => {
+ if (!showDistributions) {
+ setExpanded(true);
+ setDateRange(chartConfig.dateRange);
+ }
+ setShowDistributions(prev => !prev);
+ };
+
+ useEffect(() => {
+ if (!isLive) {
+ setDateRange(chartConfig.dateRange);
+ }
+ }, [chartConfig.dateRange, isLive]);
useEffect(() => {
if (isDefaultExpanded) {
@@ -245,6 +309,33 @@ export const FilterGroup = ({
}
}, [isDefaultExpanded]);
+ const {
+ data: distributionData,
+ isFetching: isFetchingDistribution,
+ error: distributionError,
+ } = useGetValuesDistribution(
+ {
+ chartConfig: { ...chartConfig, dateRange },
+ key: name,
+ limit: 100, // The 100 most common values are enough to find any values that are present in at least 1% of rows
+ },
+ {
+ enabled: showDistributions,
+ },
+ );
+
+ useEffect(() => {
+ if (distributionError) {
+ notifications.show({
+ color: 'red',
+ title: 'Error loading filter distribution',
+ message: distributionError?.message,
+ autoClose: 5000,
+ });
+ setShowDistributions(false);
+ }
+ }, [distributionError]);
+
const totalFiltersSize =
selectedValues.included.size + selectedValues.excluded.size;
@@ -292,6 +383,13 @@ export const FilterGroup = ({
if (aExcluded && !bExcluded) return -1;
if (!aExcluded && bExcluded) return 1;
+ // Then sort by estimated percentage of rows with this value, if available
+ const aPercentage = distributionData?.get(a.value) ?? 0;
+ const bPercentage = distributionData?.get(b.value) ?? 0;
+ if (aPercentage !== bPercentage) {
+ return bPercentage - aPercentage;
+ }
+
// Finally sort alphabetically/numerically
return a.value.localeCompare(b.value, undefined, { numeric: true });
});
@@ -310,6 +408,7 @@ export const FilterGroup = ({
augmentedOptions,
selectedValues,
totalFiltersSize,
+ distributionData,
]);
// Simple highlight animation when checkbox is checked
@@ -402,6 +501,22 @@ export const FilterGroup = ({
+
+
+
{onFieldPinClick && (
onOnlyClick(option.value)}
onClickExclude={() => onExcludeClick(option.value)}
onClickPin={() => onPinClick(option.value)}
+ isPercentageLoading={isFetchingDistribution}
+ percentage={
+ showDistributions && distributionData
+ ? (distributionData.get(option.value) ?? 0)
+ : undefined
+ }
/>
))}
{optionsLoading ? (
@@ -900,6 +1022,8 @@ const DBSearchPageFiltersComponent = ({
(filterState[facet.key].included.size > 0 ||
filterState[facet.key].excluded.size > 0))
}
+ chartConfig={chartConfig}
+ isLive={isLive}
/>
))}
diff --git a/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx b/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx
index 0b87770bc3..86b696cf32 100644
--- a/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx
+++ b/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx
@@ -1,12 +1,21 @@
+import { UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
+import { useGetValuesDistribution } from '@/hooks/useMetadata';
+
import {
cleanedFacetName,
FilterGroup,
type FilterGroupProps,
} from '../DBSearchPageFilters';
+jest.mock('@/hooks/useMetadata', () => ({
+ useGetValuesDistribution: jest
+ .fn()
+ .mockReturnValue({ data: undefined, isFetching: false, error: undefined }),
+}));
+
describe('cleanedFacetName', () => {
describe('basic functionality', () => {
it('should return non-toString strings unchanged', () => {
@@ -202,12 +211,24 @@ describe('FilterGroup', () => {
loadMoreLoading: false,
hasLoadedMore: false,
isDefaultExpanded: true,
+ chartConfig: {
+ from: {
+ databaseName: 'test_db',
+ tableName: 'test_table',
+ },
+ select: '',
+ where: '',
+ whereLanguage: 'sql',
+ timestampValueExpression: '',
+ connection: 'test_connection',
+ dateRange: [new Date('2024-01-01'), new Date('2024-01-02')],
+ },
};
it('should sort options alphabetically by default', () => {
renderWithMantine();
- const options = screen.getAllByRole('checkbox');
+ const options = screen.getAllByTestId(/filter-checkbox-input/g);
expect(options).toHaveLength(3);
const labels = screen.getAllByText(/apple|banana|zebra/);
expect(labels[0]).toHaveTextContent('apple');
@@ -226,7 +247,7 @@ describe('FilterGroup', () => {
/>,
);
- const options = screen.getAllByRole('checkbox');
+ const options = screen.getAllByTestId(/filter-checkbox-input/g);
expect(options).toHaveLength(3);
const labels = screen.getAllByText(/apple|banana|zebra/);
expect(labels[0]).toHaveTextContent('apple');
@@ -234,6 +255,68 @@ describe('FilterGroup', () => {
expect(labels[2]).toHaveTextContent('banana');
});
+ it('should show selected items first, then sort by counts, if percentages when they are enabled', () => {
+ jest.mocked(useGetValuesDistribution).mockReturnValue({
+ data: new Map([
+ ['apple', 30],
+ ['banana', 20],
+ ['zebra', 50],
+ ]),
+ isFetching: false,
+ error: null,
+ } as UseQueryResult