diff --git a/.changeset/cascading-dashboard-filters.md b/.changeset/cascading-dashboard-filters.md new file mode 100644 index 0000000000..5ad16612ff --- /dev/null +++ b/.changeset/cascading-dashboard-filters.md @@ -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. diff --git a/packages/app/src/DashboardFilters.tsx b/packages/app/src/DashboardFilters.tsx index f92ac131c8..275c1c8bd2 100644 --- a/packages/app/src/DashboardFilters.tsx +++ b/packages/app/src/DashboardFilters.tsx @@ -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'; @@ -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}`} /> @@ -88,6 +90,12 @@ 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, @@ -95,6 +103,8 @@ const DashboardFilters = ({ } = useDashboardFilterValues({ filters, dateRange, + // Only narrow by sibling selections when linked. + filterValues: linked ? filterValues : {}, }); return ( @@ -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; @@ -123,6 +133,19 @@ const DashboardFilters = ({ /> ); })} + {filters.length >= 2 && ( + + {/* Spacer to align the toggle with the inputs (filters have a label row above). */} + +   + + + + )} {isFetching && } ); diff --git a/packages/app/src/components/FilterLinkToggle.tsx b/packages/app/src/components/FilterLinkToggle.tsx new file mode 100644 index 0000000000..7c53464f6e --- /dev/null +++ b/packages/app/src/components/FilterLinkToggle.tsx @@ -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 ( + + onChange(!linked)} + aria-label="Link filters" + aria-pressed={linked} + data-testid={dataTestId} + > + + + + ); +} diff --git a/packages/app/src/components/KubernetesFilters.tsx b/packages/app/src/components/KubernetesFilters.tsx index 3367ad6359..770035c515 100644 --- a/packages/app/src/components/KubernetesFilters.tsx +++ b/packages/app/src/components/KubernetesFilters.tsx @@ -1,12 +1,18 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { escapeRegExp } from 'lodash'; import { useForm, useWatch } from 'react-hook-form'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; +import { + FilterState, + filtersToQuery, +} from '@hyperdx/common-utils/dist/filters'; import { BuilderChartConfigWithDateRange, TMetricSource, } from '@hyperdx/common-utils/dist/types'; import { Box, Group, Select } from '@mantine/core'; +import { FilterLinkToggle } from '@/components/FilterLinkToggle'; import SearchInputV2 from '@/components/SearchInput/SearchInputV2'; import { useGetKeyValues } from '@/hooks/useMetadata'; @@ -17,44 +23,84 @@ type KubernetesFiltersProps = { setSearchQuery: (query: string) => void; }; +// The bundled Kubernetes filter dropdowns, in display order. +const K8S_FILTER_FIELDS = [ + { + field: 'k8s.pod.name', + placeholder: 'Pod', + dataTestId: 'pod-filter-select', + }, + { + field: 'k8s.deployment.name', + placeholder: 'Deployment', + dataTestId: 'deployment-filter-select', + }, + { + field: 'k8s.node.name', + placeholder: 'Node', + dataTestId: 'node-filter-select', + }, + { + field: 'k8s.namespace.name', + placeholder: 'Namespace', + dataTestId: 'namespace-filter-select', + }, + { + field: 'k8s.cluster.name', + placeholder: 'Cluster', + dataTestId: 'cluster-filter-select', + }, +] as const; + type FilterSelectProps = { - metricSource: TMetricSource; placeholder: string; - fieldName: string; value: string | null; onChange: (value: string | null) => void; - chartConfig: BuilderChartConfigWithDateRange; + options: string[]; + loading?: boolean; dataTestId?: string; }; +// Removes a single field's `resourceAttr.field:"..."` clause from a Lucene +// query string, leaving every other clause (and free-text search) intact. Used +// both to rewrite the query when a dropdown changes and to build the faceted +// free-text `where` for the dropdown value lookup. +export const stripFieldClause = ( + query: string, + resourceAttr: string, + field: string, +): string => { + const fullAttribute = `${resourceAttr}.${field}`; + const regex = new RegExp(`${escapeRegExp(fullAttribute)}:"[^"]*"`, 'g'); + // Replace with a space and collapse runs of whitespace so removing a clause + // from the middle of the query doesn't leave a double space. + return query.replace(regex, ' ').replace(/\s+/g, ' ').trim(); +}; + const FilterSelect: React.FC = ({ - metricSource, placeholder, - fieldName, value, onChange, - chartConfig, + options, + loading, dataTestId, }) => { - const { data, isLoading } = useGetKeyValues({ - chartConfig, - keys: [`${metricSource.resourceAttributesExpression}['${fieldName}']`], - disableRowLimit: true, - limit: 1000000, - }); - - const options = useMemo( - () => - data?.[0]?.value - .map(value => ({ value, label: value })) - .sort((a, b) => a.value.localeCompare(b.value)) || [], // Sort alphabetically for better search results - [data], - ); + const data = useMemo(() => { + const opts = options + .map(v => ({ value: v, label: v })) + .sort((a, b) => a.value.localeCompare(b.value)); // Sort alphabetically for better search results + // Keep the current selection visible even when it isn't in the (faceted) + // value list, so the control still reflects what's applied. + if (value && !opts.some(o => o.value === value)) { + opts.unshift({ value, label: value }); + } + return opts; + }, [options, value]); return (