Skip to content
Draft
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
15 changes: 15 additions & 0 deletions .changeset/cascading-dashboard-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@hyperdx/app': patch
---

feat(dashboards): opt-in linked (faceted) filter values

Dashboard and Kubernetes filter bars gain a "link filters" toggle (the
bidirectional-arrow button at the end of the bar). When enabled, each filter
dropdown 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. It is off by default
and, when on, a dropdown's narrowed values are fetched lazily only when it is
opened, since contingent value lookups can't use the cheap per-key rollups and
are more expensive at scale. Search-page filters are unaffected.
71 changes: 63 additions & 8 deletions packages/app/src/DashboardFilters.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useCallback, useState } from 'react';
import { FilterState } from '@hyperdx/common-utils/dist/filters';
import { DashboardFilter } from '@hyperdx/common-utils/dist/types';
import { Group, Stack, Text, Tooltip } from '@mantine/core';
import { IconAlertTriangle, IconHelp, IconRefresh } from '@tabler/icons-react';

import { FilterLinkToggle } from './components/FilterLinkToggle';
import { VirtualMultiSelect } from './components/VirtualMultiSelect/VirtualMultiSelect';
import { useDashboardFilterValues } from './hooks/useDashboardFilterValues';

Expand All @@ -13,6 +15,8 @@ interface DashboardFilterSelectProps {
values?: string[];
isLoading?: boolean;
isError?: boolean;
onDropdownOpen?: () => void;
onDropdownClose?: () => void;
}

const getAppliesToTooltip = (filter: DashboardFilter) => {
Expand All @@ -28,6 +32,8 @@ const DashboardFilterSelect = ({
values,
isLoading,
isError,
onDropdownOpen,
onDropdownClose,
}: DashboardFilterSelectProps) => {
const sortedValues = values?.toSorted() || [];
const tooltipText = getAppliesToTooltip(filter);
Expand Down Expand Up @@ -63,11 +69,14 @@ const DashboardFilterSelect = ({
placeholder={value.length === 0 ? filter.name : undefined}
values={value}
data={sortedValues}
// Disable only while values are genuinely loading. A completed query
// that returned no rows (or failed) must stay interactive so the user
// can still clear/adjust the selection instead of being stuck.
disabled={isLoading}
// Surface loading as a dropdown hint rather than disabling the control:
// it must stay openable so lazy (link-mode) fetches can trigger on open,
// and a completed/empty/failed query must stay interactive so the user
// can still clear or adjust the selection.
loading={isLoading}
onChange={onChange}
onDropdownOpen={onDropdownOpen}
onDropdownClose={onDropdownClose}
data-testid={`dashboard-filter-select-${filter.name}`}
/>
</div>
Expand All @@ -88,13 +97,39 @@ const DashboardFilters = ({
filterValues,
onSetFilterValue,
}: DashboardFilterProps) => {
// "Link" mode (opt-in, off by default): each dropdown's values are narrowed by
// the others' selections. Off by default because contingent value lookups
// can't use the cheap per-key rollups and are far more expensive at scale.
const [linked, setLinked] = useState(false);
// In link mode, only fetch a filter's (constrained) values once its dropdown
// is open — bounds the extra scans to what the user actually looks at.
const [openFilterIds, setOpenFilterIds] = useState<Set<string>>(
() => new Set(),
);
const setFilterOpen = useCallback((id: string, open: boolean) => {
setOpenFilterIds(prev => {
if (open === prev.has(id)) return prev;
const next = new Set(prev);
if (open) {
next.add(id);
} else {
next.delete(id);
}
return next;
});
}, []);

const {
data: filterValuesById,
erroredFilterIds,
isFetching,
} = useDashboardFilterValues({
filters,
dateRange,
// Only narrow by sibling selections when linked.
filterValues: linked ? filterValues : {},
// Lazy fetch (open dropdowns only) when linked; eager (all) when not.
activeFilterIds: linked ? openFilterIds : undefined,
});

return (
Expand All @@ -105,12 +140,13 @@ const DashboardFilters = ({
const selectedValues = included
? Array.from(included).map(v => v.toString())
: [];
// Fall back to the hook-level fetching state only until this filter's
// query has produced an entry; once it has (even with empty values),
// honor its own loading flag so a finished query never stays disabled.
// In link mode a closed (never-opened) dropdown isn't fetched, so it
// must read as "not loading" to stay openable; otherwise fall back to
// the hook-level fetching state until this filter has produced an entry.
const isInactive = linked && !openFilterIds.has(filter.id);
const isLoadingValues = queriedFilterValues
? queriedFilterValues.isLoading
: isFetching;
: !isInactive && isFetching;
return (
<DashboardFilterSelect
key={filter.id}
Expand All @@ -120,9 +156,28 @@ const DashboardFilters = ({
onChange={values => onSetFilterValue(filter.expression, values)}
values={queriedFilterValues?.values}
value={selectedValues}
onDropdownOpen={
linked ? () => setFilterOpen(filter.id, true) : undefined
}
onDropdownClose={
linked ? () => setFilterOpen(filter.id, false) : undefined
}
/>
);
})}
{filters.length >= 2 && (
<Stack gap={2} justify="flex-end">
{/* Spacer to align the toggle with the inputs (filters have a label row above). */}
<Text size="xs" c="transparent" aria-hidden>
&nbsp;
</Text>
<FilterLinkToggle
linked={linked}
onChange={setLinked}
data-testid="dashboard-filters-link-toggle"
/>
</Stack>
)}
{isFetching && <IconRefresh className="spin-animate" size={12} />}
</Group>
);
Expand Down
45 changes: 45 additions & 0 deletions packages/app/src/components/FilterLinkToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { ActionIcon, Tooltip } from '@mantine/core';
import { IconArrowsLeftRight } from '@tabler/icons-react';

type FilterLinkToggleProps = {
linked: boolean;
onChange: (linked: boolean) => void;
'data-testid'?: string;
};

/**
* Opt-in toggle that "links" a set of filter dropdowns so each one's selectable
* values are narrowed by the others' current selections (faceted / filter-aware
* values). Off by default because contingent value lookups can't be served from
* the cheap per-key rollups and are far more expensive at scale.
*/
export function FilterLinkToggle({
linked,
onChange,
'data-testid': dataTestId = 'filter-link-toggle',
}: FilterLinkToggleProps) {
return (
<Tooltip
withinPortal
multiline
w={250}
label={
linked
? 'Filters are linked: each dropdown only shows values that match the other selections. Click to unlink.'
: 'Link filters: narrow each dropdown to values that match the other selections (filter-aware). May be slower on large datasets.'
}
>
<ActionIcon
// Theme-defined variants only (no raw color): the "secondary" variant's
// surface + border reads as the pressed/active state, "subtle" as off.
variant={linked ? 'secondary' : 'subtle'}
onClick={() => onChange(!linked)}
aria-label="Link filters"
aria-pressed={linked}
data-testid={dataTestId}
>
<IconArrowsLeftRight size={16} />
</ActionIcon>
</Tooltip>
);
}
Loading
Loading