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
16 changes: 16 additions & 0 deletions .changeset/cascading-dashboard-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@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
because contingent value lookups can't use the cheap per-key rollups and are
more expensive at scale; when on, all of a source's facets are computed in a
single `groupUniqArrayIf` scan rather than one query per filter. Search-page
filters are unaffected.
33 changes: 28 additions & 5 deletions packages/app/src/DashboardFilters.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { 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 Down Expand Up @@ -63,10 +65,10 @@ 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,
// so a completed/empty/failed query stays interactive and the user can
// still clear or adjust the selection.
loading={isLoading}
onChange={onChange}
data-testid={`dashboard-filter-select-${filter.name}`}
/>
Expand All @@ -88,13 +90,21 @@ 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 more expensive at scale. When
// on, all of a source's facets are computed in a single groupUniqArrayIf scan.
const [linked, setLinked] = useState(false);

const {
data: filterValuesById,
erroredFilterIds,
isFetching,
} = useDashboardFilterValues({
filters,
dateRange,
// Only narrow by sibling selections when linked.
filterValues: linked ? filterValues : {},
});

return (
Expand All @@ -107,7 +117,7 @@ const DashboardFilters = ({
: [];
// 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.
// honor its own loading flag.
const isLoadingValues = queriedFilterValues
? queriedFilterValues.isLoading
: isFetching;
Expand All @@ -123,6 +133,19 @@ const DashboardFilters = ({
/>
);
})}
{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
56 changes: 56 additions & 0 deletions packages/app/src/components/FilterLinkToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { CSSProperties } from 'react';
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
variant="subtle"
// Active state: a high-contrast inverted "pill" via theme tokens (never
// green, on any theme) so linked mode is clearly visible; off is the
// plain subtle icon. Overrides Mantine's ActionIcon CSS vars inline.
style={
linked
? ({
'--ai-bg': 'var(--color-bg-inverted)',
'--ai-hover': 'var(--color-bg-inverted)',
'--ai-color': 'var(--color-text-inverted)',
} as CSSProperties)
: undefined
}
onClick={() => onChange(!linked)}
aria-label="Link filters"
aria-pressed={linked}
data-testid={dataTestId}
>
<IconArrowsLeftRight size={16} />
</ActionIcon>
</Tooltip>
);
}
Loading
Loading