Skip to content

Commit b2165b4

Browse files
authored
feat(dashboards): cascading (faceted) filter values (#2423)
## Summary Adds opt-in **linked (faceted) filter values** to the dashboard and Kubernetes filter bars. A bidirectional-arrow "link filters" toggle at the end of each bar (off by default) turns on filter-awareness: each dropdown then only shows values that co-occur with the other current selections — e.g. picking a `cluster` narrows the `namespace` dropdown to namespaces in that cluster (the K8s bar also factors in the free-text search). A filter never constrains its own options, so multi-select still works. When linked, a dropdown's narrowed values are fetched lazily only when it is opened, which bounds the cost of these contingent lookups — they can't be served from the cheap per-key rollups and are far more expensive at scale, so they stay opt-in and off by default. Search-page filters are intentionally untouched. Addresses HDX-4462. ### Screenshots or video [Kubernetes-Dashboard-–-ClickStack.webm](https://github.com/user-attachments/assets/b0414f45-0bdd-45e2-9947-b77b511facd1) ### How to test on Vercel preview **Preview routes:** /kubernetes **Steps:** 1. Open `/kubernetes`. 2. Verify the filter bar renders the `Cluster` and `Namespace` dropdowns (`cluster-filter-select`, `namespace-filter-select`) and the link-filters toggle (`k8s-filters-link-toggle`). 3. Click the link-filters toggle and confirm it becomes active (pressed) without any console errors. ### References - Linear Issue: https://linear.app/clickhouse/issue/HDX-4462 - Related PRs: —
1 parent ab190d1 commit b2165b4

15 files changed

Lines changed: 1410 additions & 171 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@hyperdx/app': patch
3+
---
4+
5+
feat(dashboards): opt-in linked (faceted) filter values
6+
7+
Dashboard and Kubernetes filter bars gain a "link filters" toggle (the
8+
bidirectional-arrow button at the end of the bar). When enabled, each filter
9+
dropdown only shows values that co-occur with the other current selections —
10+
e.g. picking a `cluster` narrows the `namespace` dropdown to namespaces in that
11+
cluster (the K8s bar also factors in the free-text search). A filter never
12+
constrains its own options, so multi-select still works. It is off by default
13+
because contingent value lookups can't use the cheap per-key rollups and are
14+
more expensive at scale; when on, all of a source's facets are computed in a
15+
single `groupUniqArrayIf` scan rather than one query per filter. Search-page
16+
filters are unaffected.

‎packages/app/src/DashboardFilters.tsx‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { useMemo } from 'react';
1+
import { useMemo, useState } from 'react';
22
import { FilterState } from '@hyperdx/common-utils/dist/filters';
33
import { DashboardFilter } from '@hyperdx/common-utils/dist/types';
44
import { Group, Stack, Text, Tooltip } from '@mantine/core';
55
import { IconAlertTriangle, IconHelp, IconRefresh } from '@tabler/icons-react';
66

7+
import { FilterLinkToggle } from './components/FilterLinkToggle';
78
import { VirtualMultiSelect } from './components/VirtualMultiSelect/VirtualMultiSelect';
89
import { useDashboardFilterValues } from './hooks/useDashboardFilterValues';
910

@@ -64,10 +65,10 @@ const DashboardFilterSelect = ({
6465
placeholder={value.length === 0 ? filter.name : undefined}
6566
values={value}
6667
data={valuesOrEmptyMemo}
67-
// Disable only while values are genuinely loading. A completed query
68-
// that returned no rows (or failed) must stay interactive so the user
69-
// can still clear/adjust the selection instead of being stuck.
70-
disabled={isLoading}
68+
// Surface loading as a dropdown hint rather than disabling the control,
69+
// so a completed/empty/failed query stays interactive and the user can
70+
// still clear or adjust the selection.
71+
loading={isLoading}
7172
onChange={onChange}
7273
data-testid={`dashboard-filter-select-${filter.name}`}
7374
/>
@@ -89,13 +90,21 @@ const DashboardFilters = ({
8990
filterValues,
9091
onSetFilterValue,
9192
}: DashboardFilterProps) => {
93+
// "Link" mode (opt-in, off by default): each dropdown's values are narrowed by
94+
// the others' selections. Off by default because contingent value lookups
95+
// can't use the cheap per-key rollups and are more expensive at scale. When
96+
// on, all of a source's facets are computed in a single groupUniqArrayIf scan.
97+
const [linked, setLinked] = useState(false);
98+
9299
const {
93100
data: filterValuesById,
94101
erroredFilterIds,
95102
isFetching,
96103
} = useDashboardFilterValues({
97104
filters,
98105
dateRange,
106+
// Only narrow by sibling selections when linked.
107+
filterValues: linked ? filterValues : {},
99108
});
100109

101110
return (
@@ -108,7 +117,7 @@ const DashboardFilters = ({
108117
: [];
109118
// Fall back to the hook-level fetching state only until this filter's
110119
// query has produced an entry; once it has (even with empty values),
111-
// honor its own loading flag so a finished query never stays disabled.
120+
// honor its own loading flag.
112121
const isLoadingValues = queriedFilterValues
113122
? queriedFilterValues.isLoading
114123
: isFetching;
@@ -124,6 +133,19 @@ const DashboardFilters = ({
124133
/>
125134
);
126135
})}
136+
{filters.length >= 2 && (
137+
<Stack gap={2} justify="flex-end">
138+
{/* Spacer to align the toggle with the inputs (filters have a label row above). */}
139+
<Text size="xs" c="transparent" aria-hidden>
140+
&nbsp;
141+
</Text>
142+
<FilterLinkToggle
143+
linked={linked}
144+
onChange={setLinked}
145+
data-testid="dashboard-filters-link-toggle"
146+
/>
147+
</Stack>
148+
)}
127149
{isFetching && <IconRefresh className="spin-animate" size={12} />}
128150
</Group>
129151
);
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { CSSProperties } from 'react';
2+
import { ActionIcon, Tooltip } from '@mantine/core';
3+
import { IconArrowsLeftRight } from '@tabler/icons-react';
4+
5+
type FilterLinkToggleProps = {
6+
linked: boolean;
7+
onChange: (linked: boolean) => void;
8+
'data-testid'?: string;
9+
};
10+
11+
/**
12+
* Opt-in toggle that "links" a set of filter dropdowns so each one's selectable
13+
* values are narrowed by the others' current selections (faceted / filter-aware
14+
* values). Off by default because contingent value lookups can't be served from
15+
* the cheap per-key rollups and are far more expensive at scale.
16+
*/
17+
export function FilterLinkToggle({
18+
linked,
19+
onChange,
20+
'data-testid': dataTestId = 'filter-link-toggle',
21+
}: FilterLinkToggleProps) {
22+
return (
23+
<Tooltip
24+
withinPortal
25+
multiline
26+
w={250}
27+
label={
28+
linked
29+
? 'Filters are linked: each dropdown only shows values that match the other selections. Click to unlink.'
30+
: 'Link filters: narrow each dropdown to values that match the other selections (filter-aware). May be slower on large datasets.'
31+
}
32+
>
33+
<ActionIcon
34+
variant="subtle"
35+
// Active state: a high-contrast inverted "pill" via theme tokens (never
36+
// green, on any theme) so linked mode is clearly visible; off is the
37+
// plain subtle icon. Overrides Mantine's ActionIcon CSS vars inline.
38+
style={
39+
linked
40+
? ({
41+
'--ai-bg': 'var(--color-bg-inverted)',
42+
'--ai-hover': 'var(--color-bg-inverted)',
43+
'--ai-color': 'var(--color-text-inverted)',
44+
} as CSSProperties)
45+
: undefined
46+
}
47+
onClick={() => onChange(!linked)}
48+
aria-label="Link filters"
49+
aria-pressed={linked}
50+
data-testid={dataTestId}
51+
>
52+
<IconArrowsLeftRight size={16} />
53+
</ActionIcon>
54+
</Tooltip>
55+
);
56+
}

0 commit comments

Comments
 (0)