From ed42b33b173c7c6f4b9d101760ee4c10c4a28cd4 Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Fri, 9 Oct 2026 11:48:38 -0400 Subject: [PATCH] feat: Render adhoc dashboard filter inputs --- .changeset/adhoc-filter-inputs.md | 6 + packages/app/src/DBDashboardPage.tsx | 13 +- packages/app/src/DashboardFilters.tsx | 73 +++--- .../src/__tests__/DashboardFilters.test.ts | 17 ++ .../AdhocConditionEditor.tsx | 65 +++++ .../AdhocDashboardFilter.tsx | 144 +++++++++++ .../__tests__/AdhocDashboardFilter.test.tsx | 171 ++++++++++++ .../__tests__/useAdhocFilterOptions.test.ts | 76 ++++++ .../useAdhocFilterOptions.ts | 244 ++++++++++++++++++ .../src/components/DashboardFilterLabel.tsx | 58 +++++ .../DashboardFiltersList.tsx | 9 - .../FilterPill/FilterAutocomplete.tsx | 2 +- .../FilterConditionEditor.stories.tsx | 13 + .../FilterPill/FilterConditionEditor.tsx | 8 + .../__tests__/FilterConditionEditor.test.tsx | 14 + .../__tests__/useDashboardFilters.test.tsx | 103 ++++++++ .../usePresetDashboardFilters.test.tsx | 2 + .../app/src/hooks/useDashboardFilters.tsx | 74 +++++- .../src/hooks/usePromqlLabelFilterValues.tsx | 20 +- packages/app/src/hooks/usePromqlMetadata.ts | 42 ++- .../features/dashboard-adhoc-filters.spec.ts | 217 +++++++++++++++- .../tests/e2e/page-objects/DashboardPage.ts | 92 ++++++- packages/app/tests/e2e/utils/filters-param.ts | 12 +- .../__tests__/dashboardFilterValues.test.ts | 88 ++++++- .../src/__tests__/filters.test.ts | 26 +- .../common-utils/src/dashboardFilterValues.ts | 39 ++- packages/common-utils/src/filters.ts | 16 +- 27 files changed, 1518 insertions(+), 126 deletions(-) create mode 100644 .changeset/adhoc-filter-inputs.md create mode 100644 packages/app/src/components/AdhocDashboardFilter/AdhocConditionEditor.tsx create mode 100644 packages/app/src/components/AdhocDashboardFilter/AdhocDashboardFilter.tsx create mode 100644 packages/app/src/components/AdhocDashboardFilter/__tests__/AdhocDashboardFilter.test.tsx create mode 100644 packages/app/src/components/AdhocDashboardFilter/__tests__/useAdhocFilterOptions.test.ts create mode 100644 packages/app/src/components/AdhocDashboardFilter/useAdhocFilterOptions.ts create mode 100644 packages/app/src/components/DashboardFilterLabel.tsx diff --git a/.changeset/adhoc-filter-inputs.md b/.changeset/adhoc-filter-inputs.md new file mode 100644 index 0000000000..48162bfae0 --- /dev/null +++ b/.changeset/adhoc-filter-inputs.md @@ -0,0 +1,6 @@ +--- +'@hyperdx/common-utils': patch +'@hyperdx/app': patch +--- + +feat: Render adhoc dashboard filter inputs diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index b7e102324b..cc6c0c873b 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -35,7 +35,6 @@ import { isTimeSeriesDisplayType, } from '@hyperdx/common-utils/dist/core/utils'; import { getBlockingRequiredFilterNames } from '@hyperdx/common-utils/dist/dashboardFilterValues'; -import { isAdhocFilter } from '@hyperdx/common-utils/dist/filters'; import { displayTypeRequiresSource, isBuilderChartConfig, @@ -1979,14 +1978,12 @@ function DBDashboardPage({ const [showFiltersModal, setShowFiltersModal] = useState(false); - // Ad hoc filters aren't rendered or applied yet - const filters = useMemo( - () => (dashboard?.filters ?? []).filter(filter => !isAdhocFilter(filter)), - [dashboard?.filters], - ); + const filters = useMemo(() => dashboard?.filters ?? [], [dashboard?.filters]); const { selectionByFilterId, setFilterValue, + adhocConditionsByFilterId, + setAdhocConditions, setFilterValueEntries, filterValueEntries, ignoredFilterExpressions, @@ -3351,6 +3348,8 @@ function DBDashboardPage({ filters={filters} selectionByFilterId={selectionByFilterId} onSetFilterValue={setFilterValue} + adhocConditionsByFilterId={adhocConditionsByFilterId} + onSetAdhocConditions={setAdhocConditions} dateRange={searchedTimeRange} variables={variables} /> @@ -3524,7 +3523,7 @@ function DBDashboardPage({ setShowFiltersModal(false)} - filters={dashboard?.filters ?? []} + filters={filters} onSaveFilter={handleSaveFilter} onRemoveFilter={handleRemoveFilter} isLoading={isSavingDashboard || isFetchingDashboard} diff --git a/packages/app/src/DashboardFilters.tsx b/packages/app/src/DashboardFilters.tsx index 9915d1154c..1fe0306d2d 100644 --- a/packages/app/src/DashboardFilters.tsx +++ b/packages/app/src/DashboardFilters.tsx @@ -4,6 +4,7 @@ import { getFilterBroadcastTarget, getFilterVariableName, getPendingFilterValuesVariables, + isAdhocFilter, isFilterGlobalRequirement, isFilterRequired, isFilterVariableEnabled, @@ -11,13 +12,19 @@ import { isStaticListFilter, } from '@hyperdx/common-utils/dist/filters'; import { + AdhocFilterCondition, ChartVariable, DashboardFilter, } from '@hyperdx/common-utils/dist/types'; import { isFilterSingleSelect } from '@hyperdx/common-utils/dist/variables'; -import { Group, Stack, Text, Tooltip } from '@mantine/core'; -import { IconAlertTriangle, IconHelp, IconRefresh } from '@tabler/icons-react'; +import { Group, Stack, Text } from '@mantine/core'; +import { IconRefresh } from '@tabler/icons-react'; +import { AdhocDashboardFilter } from './components/AdhocDashboardFilter/AdhocDashboardFilter'; +import { + DashboardFilterLabel, + FilterCaution, +} from './components/DashboardFilterLabel'; import { FilterLinkToggle } from './components/FilterLinkToggle'; import { VirtualMultiSelect } from './components/VirtualMultiSelect/VirtualMultiSelect'; import { useDashboardFilterValues } from './hooks/useDashboardFilterValues'; @@ -99,25 +106,6 @@ export const getFilterEffect = ( return { hasEffect: true, tooltip: parts.join(', ') }; }; -/** One of the caution icons a filter's label row can carry, with its tooltip. */ -const FilterCaution = ({ - label, - testId, - variant = 'warning', -}: { - label: string; - testId: string; - variant?: 'warning' | 'danger'; -}) => ( - - - -); - const DashboardFilterSelect = ({ filter, onChange, @@ -134,25 +122,7 @@ const DashboardFilterSelect = ({ return ( - - - {filter.name} - - - {effect.hasEffect ? ( - - ) : ( - - )} - + {isMissingRequiredValue && ( )} - +
; onSetFilterValue: (filterId: string, values: string[]) => void; + adhocConditionsByFilterId?: ReadonlyMap; + onSetAdhocConditions?: ( + filterId: string, + conditions: AdhocFilterCondition[], + ) => void; dateRange: [Date, Date]; /** * The dashboard's variables and their current selections. Defined only when @@ -217,6 +192,8 @@ const DashboardFilters = ({ dateRange, selectionByFilterId, onSetFilterValue, + adhocConditionsByFilterId, + onSetAdhocConditions, variables, }: DashboardFilterProps) => { // "Link" mode (opt-in, off by default): each dropdown's values are narrowed by @@ -248,6 +225,20 @@ const DashboardFilters = ({ return ( {filters.map(filter => { + if (isAdhocFilter(filter)) { + return ( + + onSetAdhocConditions?.(filter.id, conditions) + } + dateRange={dateRange} + effect={getFilterEffect(filter)} + /> + ); + } const queriedFilterValues = filterValuesById?.get(filter.id); const included = selectionByFilterId.get(filter.id)?.included; const selectedValues = included diff --git a/packages/app/src/__tests__/DashboardFilters.test.ts b/packages/app/src/__tests__/DashboardFilters.test.ts index d063d63a9c..5c5df455ab 100644 --- a/packages/app/src/__tests__/DashboardFilters.test.ts +++ b/packages/app/src/__tests__/DashboardFilters.test.ts @@ -117,6 +117,23 @@ describe('getFilterEffect', () => { }).tooltip, ).toEqual('Available as variable ($svc)'); }); + + it('counts the sources an ad hoc filter broadcasts to', () => { + expect( + getFilterEffect({ + id: 'adhoc1', + type: 'ADHOC', + name: 'Conditions', + sourceType: 'sql', + sources: ['logs', 'traces'], + isVariableEnabled: true, + variableName: 'conds', + }), + ).toEqual({ + hasEffect: true, + tooltip: 'Filters 2 sources, available as variable ($conds)', + }); + }); }); describe('getPendingVariablesTooltip', () => { diff --git a/packages/app/src/components/AdhocDashboardFilter/AdhocConditionEditor.tsx b/packages/app/src/components/AdhocDashboardFilter/AdhocConditionEditor.tsx new file mode 100644 index 0000000000..0a3ca37a58 --- /dev/null +++ b/packages/app/src/components/AdhocDashboardFilter/AdhocConditionEditor.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react'; +import { + AdhocDashboardFilter, + AdhocFilterCondition, +} from '@hyperdx/common-utils/dist/types'; + +import { + FilterConditionEditor, + FilterOperator, + getFilterOperators, +} from '@/components/FilterPill'; + +import { + useAdhocFilterKeys, + useAdhocFilterValues, +} from './useAdhocFilterOptions'; + +type AdhocConditionEditorProps = { + filter: AdhocDashboardFilter; + dateRange: [Date, Date]; + /** The condition being edited; unset when adding one. */ + initial?: AdhocFilterCondition; + onSubmit: (condition: AdhocFilterCondition) => void; +}; + +/** + * Pick a condition from the filter's keys and values. Mounted only while its + * popover is open, so keys load on open and values once a key is picked. + */ +export function AdhocConditionEditor({ + filter, + dateRange, + initial, + onSubmit, +}: AdhocConditionEditorProps) { + const [valueKey, setValueKey] = useState(initial?.key ?? ''); + const keys = useAdhocFilterKeys(filter, dateRange); + const values = useAdhocFilterValues( + filter, + valueKey, + dateRange, + keys.keysBySourceId, + ); + const isPromql = filter.sourceType === 'promql'; + + return ( + + operators={getFilterOperators(filter.sourceType)} + initial={initial} + keyOptions={keys.data} + isLoadingKeys={keys.isLoading} + isKeysError={keys.isError} + valueOptions={values.data} + isLoadingValues={values.isLoading} + isValuesError={values.isError} + onKeyChange={setValueKey} + keyLabel={isPromql ? 'Label' : 'Key'} + keyPlaceholder={ + isPromql ? 'Select a label' : 'Select a column or map key' + } + onSubmit={onSubmit} + data-testid={`adhoc-condition-editor-${filter.name}`} + /> + ); +} diff --git a/packages/app/src/components/AdhocDashboardFilter/AdhocDashboardFilter.tsx b/packages/app/src/components/AdhocDashboardFilter/AdhocDashboardFilter.tsx new file mode 100644 index 0000000000..d8b936da3e --- /dev/null +++ b/packages/app/src/components/AdhocDashboardFilter/AdhocDashboardFilter.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import { + AdhocDashboardFilter as AdhocDashboardFilterType, + AdhocFilterCondition, +} from '@hyperdx/common-utils/dist/types'; +import { + ActionIcon, + CloseButton, + Group, + Popover, + Stack, + Text, +} from '@mantine/core'; +import { IconPlus } from '@tabler/icons-react'; + +import { DashboardFilterLabel } from '@/components/DashboardFilterLabel'; +import { FilterPill, getFilterOperators } from '@/components/FilterPill'; + +import { AdhocConditionEditor } from './AdhocConditionEditor'; + +type AdhocDashboardFilterProps = { + filter: AdhocDashboardFilterType; + conditions: AdhocFilterCondition[]; + onChange: (conditions: AdhocFilterCondition[]) => void; + dateRange: [Date, Date]; + effect: { hasEffect: boolean; tooltip: string }; +}; + +/** A dashboard filter bar control holding one ad hoc filter's conditions. */ +export function AdhocDashboardFilter({ + filter, + conditions, + onChange, + dateRange, + effect, +}: AdhocDashboardFilterProps) { + const [isAdding, setIsAdding] = useState(false); + const operators = getFilterOperators(filter.sourceType); + + const add = (condition: AdhocFilterCondition) => + onChange([...conditions, condition]); + const replace = (index: number, condition: AdhocFilterCondition) => + onChange( + conditions.map((existing, i) => (i === index ? condition : existing)), + ); + const remove = (index: number) => + onChange(conditions.filter((_existing, i) => i !== index)); + + return ( + + + + {conditions.map((condition, index) => { + const operator = operators.find( + op => op.value === condition.operator, + ); + return ( + remove(index)} + renderPopover={close => ( + { + replace(index, next); + close(); + }} + /> + )} + data-testid="adhoc-condition-pill" + /> + ); + })} + {conditions.length === 0 && ( + setIsAdding(true)} + style={{ cursor: 'pointer', flex: 1 }} + > + {filter.name} + + )} + + {conditions.length > 0 && ( + onChange([])} + data-testid={`adhoc-filter-clear-${filter.name}`} + /> + )} + + + setIsAdding(!isAdding)} + data-testid={`adhoc-filter-add-${filter.name}`} + > + + + + + { + add(condition); + setIsAdding(false); + }} + /> + + + + + + ); +} diff --git a/packages/app/src/components/AdhocDashboardFilter/__tests__/AdhocDashboardFilter.test.tsx b/packages/app/src/components/AdhocDashboardFilter/__tests__/AdhocDashboardFilter.test.tsx new file mode 100644 index 0000000000..184be86d04 --- /dev/null +++ b/packages/app/src/components/AdhocDashboardFilter/__tests__/AdhocDashboardFilter.test.tsx @@ -0,0 +1,171 @@ +import { + AdhocDashboardFilter as AdhocDashboardFilterType, + AdhocFilterCondition, +} from '@hyperdx/common-utils/dist/types'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { AdhocDashboardFilter } from '@/components/AdhocDashboardFilter/AdhocDashboardFilter'; +import { + useAdhocFilterKeys, + useAdhocFilterValues, +} from '@/components/AdhocDashboardFilter/useAdhocFilterOptions'; + +jest.mock('@/components/AdhocDashboardFilter/useAdhocFilterOptions', () => ({ + useAdhocFilterKeys: jest.fn(), + useAdhocFilterValues: jest.fn(), +})); + +const filter: AdhocDashboardFilterType = { + id: 'adhoc1', + type: 'ADHOC', + name: 'Conditions', + sourceType: 'sql', + sources: ['logs'], + isVariableEnabled: true, + variableName: 'conds', +}; + +const dateRange: [Date, Date] = [new Date(0), new Date(1000)]; + +const existing: AdhocFilterCondition = { + key: 'ServiceName', + operator: '!=', + value: 'api', +}; + +const editorId = 'adhoc-condition-editor-Conditions'; + +const renderFilter = (conditions: AdhocFilterCondition[]) => { + const onChange = jest.fn(); + renderWithMantine( + , + ); + return onChange; +}; + +describe('AdhocDashboardFilter', () => { + beforeEach(() => { + jest.mocked(useAdhocFilterKeys).mockReturnValue({ + data: ['ServiceName', 'SeverityText'], + keysBySourceId: new Map(), + isLoading: false, + isError: false, + }); + jest.mocked(useAdhocFilterValues).mockReturnValue({ + data: ['api', 'web'], + isLoading: false, + isError: false, + }); + }); + + it('renders a pill per condition', () => { + renderFilter([existing, { key: 'Level', operator: '=~', value: '^err' }]); + + const pills = screen.getAllByTestId('adhoc-condition-pill'); + expect(pills[0]).toHaveTextContent('ServiceName != api'); + expect(pills[1]).toHaveTextContent('Level regex ^err'); + }); + + it('removes a condition', async () => { + const onChange = renderFilter([ + existing, + { key: 'Level', operator: '=', value: 'error' }, + ]); + + await userEvent.click( + screen.getAllByRole('button', { name: 'Remove filter' })[0], + ); + + expect(onChange).toHaveBeenCalledWith([ + { key: 'Level', operator: '=', value: 'error' }, + ]); + }); + + it('suggests values once a key is picked', async () => { + jest.mocked(useAdhocFilterValues).mockImplementation((_filter, key) => ({ + data: key === 'SeverityText' ? ['error', 'warn'] : [], + isLoading: false, + isError: false, + })); + renderFilter([]); + + await userEvent.click(screen.getByTestId('adhoc-filter-add-Conditions')); + const valueInput = await screen.findByTestId(`${editorId}-value`); + await userEvent.click(valueInput); + expect( + screen.queryByRole('option', { name: 'error', hidden: true }), + ).not.toBeInTheDocument(); + + await userEvent.type(screen.getByTestId(`${editorId}-key`), 'SeverityText'); + await userEvent.click(valueInput); + expect( + await screen.findByRole('option', { name: 'error', hidden: true }), + ).toBeInTheDocument(); + }); + + it('shows an error when key lookup fails', async () => { + jest.mocked(useAdhocFilterKeys).mockReturnValue({ + data: [], + keysBySourceId: new Map(), + isLoading: false, + isError: true, + }); + renderFilter([]); + + await userEvent.click(screen.getByTestId('adhoc-filter-add-Conditions')); + expect( + await screen.findByText("Couldn't load suggestions"), + ).toBeInTheDocument(); + }); + + it('adds a condition', async () => { + const onChange = renderFilter([existing]); + + await userEvent.click(screen.getByTestId('adhoc-filter-add-Conditions')); + await userEvent.type( + await screen.findByTestId(`${editorId}-key`), + 'SeverityText', + ); + await userEvent.type(screen.getByTestId(`${editorId}-value`), 'error'); + await userEvent.click(screen.getByTestId(`${editorId}-apply`)); + + expect(onChange).toHaveBeenCalledWith([ + existing, + { key: 'SeverityText', operator: '=', value: 'error' }, + ]); + }); + + it('edits a condition from its pill', async () => { + const onChange = renderFilter([ + existing, + { key: 'Level', operator: '=', value: 'error' }, + ]); + + await userEvent.click(screen.getAllByTestId('adhoc-condition-pill')[0]); + const valueInput = await screen.findByTestId(`${editorId}-value`); + expect(valueInput).toHaveValue('api'); + await userEvent.clear(valueInput); + await userEvent.type(valueInput, 'web'); + await userEvent.click(screen.getByTestId(`${editorId}-apply`)); + + expect(onChange).toHaveBeenCalledWith([ + { key: 'ServiceName', operator: '!=', value: 'web' }, + { key: 'Level', operator: '=', value: 'error' }, + ]); + }); + + it('clears every condition', async () => { + const onChange = renderFilter([existing]); + + await userEvent.click(screen.getByTestId('adhoc-filter-clear-Conditions')); + + expect(onChange).toHaveBeenCalledWith([]); + }); +}); diff --git a/packages/app/src/components/AdhocDashboardFilter/__tests__/useAdhocFilterOptions.test.ts b/packages/app/src/components/AdhocDashboardFilter/__tests__/useAdhocFilterOptions.test.ts new file mode 100644 index 0000000000..a586385f92 --- /dev/null +++ b/packages/app/src/components/AdhocDashboardFilter/__tests__/useAdhocFilterOptions.test.ts @@ -0,0 +1,76 @@ +import { ColumnMeta, JSDataType } from '@hyperdx/common-utils/dist/clickhouse'; + +import { + getSourcesWithKey, + getSqlSourceKeys, +} from '@/components/AdhocDashboardFilter/useAdhocFilterOptions'; + +const column = (name: string, type: string): ColumnMeta => ({ + name, + type, + codec_expression: '', + comment: '', + default_expression: '', + default_type: '', + ttl_expression: '', +}); + +describe('getSqlSourceKeys', () => { + const columns = [ + column('ServiceName', 'LowCardinality(String)'), + column('LogAttributes', 'Map(LowCardinality(String), String)'), + column('Body', 'JSON'), + column('Events', 'Array(String)'), + ]; + + it('offers scalar columns and the keys inside map and JSON columns', () => { + expect( + getSqlSourceKeys( + [ + { path: ['ServiceName'], type: 'String', jsType: JSDataType.String }, + { path: ['LogAttributes', '1'], type: 'String', jsType: null }, + { path: ['Body', 'user.id'], type: 'String', jsType: null }, + ], + columns, + ), + ).toEqual(['ServiceName', "LogAttributes['1']", 'Body.`user`.`id`']); + }); + + it('leaves out whole map, JSON, array, and tuple columns', () => { + expect( + getSqlSourceKeys( + [ + { path: ['LogAttributes'], type: 'Map', jsType: JSDataType.Map }, + { path: ['Body'], type: 'JSON', jsType: JSDataType.JSON }, + { path: ['Events'], type: 'Array', jsType: JSDataType.Array }, + { path: ['Pair'], type: 'Tuple', jsType: JSDataType.Tuple }, + ], + columns, + ), + ).toEqual([]); + }); +}); + +describe('getSourcesWithKey', () => { + const sources = [{ id: 'logs' }, { id: 'traces' }]; + + it('looks up a key only in the sources known to have it', () => { + const keysBySourceId = new Map([ + ['logs', ['ServiceName', 'SeverityText']], + ['traces', ['ServiceName', 'SpanName']], + ]); + + expect(getSourcesWithKey(sources, keysBySourceId, 'SeverityText')).toEqual([ + { id: 'logs' }, + ]); + expect(getSourcesWithKey(sources, keysBySourceId, 'ServiceName')).toEqual( + sources, + ); + }); + + it('looks up a key no source is known to have in every source', () => { + expect( + getSourcesWithKey(sources, new Map([['logs', ['ServiceName']]]), 'typed'), + ).toEqual(sources); + }); +}); diff --git a/packages/app/src/components/AdhocDashboardFilter/useAdhocFilterOptions.ts b/packages/app/src/components/AdhocDashboardFilter/useAdhocFilterOptions.ts new file mode 100644 index 0000000000..12c06256d9 --- /dev/null +++ b/packages/app/src/components/AdhocDashboardFilter/useAdhocFilterOptions.ts @@ -0,0 +1,244 @@ +import { useCallback, useMemo } from 'react'; +import { + ColumnMeta, + filterColumnMetaByType, + JSDataType, + QueryAttribution, +} from '@hyperdx/common-utils/dist/clickhouse'; +import { Field, tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; +import { getAdhocFilterSourceKinds } from '@hyperdx/common-utils/dist/filters'; +import { + AdhocDashboardFilter, + isPromqlSource, + TSource, +} from '@hyperdx/common-utils/dist/types'; +import { + keepPreviousData, + useQueries, + UseQueryOptions, + UseQueryResult, +} from '@tanstack/react-query'; + +import api from '@/api'; +import { useMetadataWithSettings } from '@/hooks/useMetadata'; +import { + fetchPromqlLabelNames, + fetchPromqlLabelValues, + toPromqlSeconds, +} from '@/hooks/usePromqlMetadata'; +import { useMetadataQueryAttribution } from '@/queryAttribution'; +import { pickSourceConfigFields } from '@/ServicesDashboardPage/helpers'; +import { useSources } from '@/source'; +import { mergePath } from '@/utils'; + +const STALE_TIME_MS = 1000 * 60 * 5; +const VALUES_LIMIT = 100; + +/** Columns that hold structure rather than a filterable value. */ +const NON_SCALAR_TYPES = [ + JSDataType.Map, + JSDataType.JSON, + JSDataType.Array, + JSDataType.Tuple, +]; + +const unionSorted = (lists: (string[] | undefined)[]) => + Array.from(new Set(lists.flatMap(list => list ?? []))).sort(); + +const promqlLookupParams = ( + source: TSource, + dateRange: [Date, Date], + attribution: QueryAttribution, +) => ({ + connectionId: source.connection, + database: source.from.databaseName, + table: source.from.tableName, + ...toPromqlSeconds(dateRange), + attribution, +}); + +/** + * A SQL source's filterable keys, in the search sidebar's key format: scalar + * columns, and the keys inside its map and JSON columns. + */ +export function getSqlSourceKeys(fields: Field[], columns: ColumnMeta[]) { + const columnNames = (types: JSDataType[]) => + filterColumnMetaByType(columns, types)?.map(column => column.name) ?? []; + const jsonColumns = columnNames([JSDataType.JSON]); + const mapColumns = columnNames([JSDataType.Map]); + return fields + .filter( + field => + field.path.length > 1 || + field.jsType == null || + !NON_SCALAR_TYPES.includes(field.jsType), + ) + .map(field => mergePath(field.path, jsonColumns, mapColumns)); +} + +/** + * The sources to look up `key`'s values in: those known to have it, so a source + * without the column doesn't fail its query, or every source when none are + * known to (a key typed by hand). + */ +export function getSourcesWithKey( + sources: T[], + keysBySourceId: ReadonlyMap, + key: string, +) { + const withKey = sources.filter(source => + keysBySourceId.get(source.id)?.includes(key), + ); + return withKey.length > 0 ? withKey : sources; +} + +/** The filter's sources, limited to the kinds its source type reads. */ +export function useAdhocFilterSources(filter: AdhocDashboardFilter) { + const { data: allSources } = useSources(); + return useMemo(() => { + const kinds = getAdhocFilterSourceKinds(filter.sourceType); + return (allSources ?? []).filter( + source => + filter.sources.includes(source.id) && kinds.includes(source.kind), + ); + }, [allSources, filter.sources, filter.sourceType]); +} + +/** + * Keys offered by an ad hoc filter: columns and map / JSON keys of its SQL + * sources, in the search sidebar's key format, or label names of its PromQL + * sources. Deduplicated across sources. + */ +export function useAdhocFilterKeys( + filter: AdhocDashboardFilter, + dateRange: [Date, Date], +) { + const metadata = useMetadataWithSettings(); + const attribution = useMetadataQueryAttribution(); + const { data: me, isFetched: isMeFetched } = api.useMe(); + const isFieldMetadataDisabled = !!me?.team?.fieldMetadataDisabled; + const sources = useAdhocFilterSources(filter); + const { start, end } = toPromqlSeconds(dateRange); + + const fetchKeys = async (source: TSource): Promise => { + if (isPromqlSource(source)) { + const labels = await fetchPromqlLabelNames( + promqlLookupParams(source, dateRange, attribution), + ); + return labels.filter(label => label !== '__name__'); + } + + const tc = tcFromSource(source); + const columns = await metadata.getColumns(tc); + if (isFieldMetadataDisabled) { + return columns.map(column => column.name); + } + const fields = await metadata.getAllFields({ ...tc, dateRange }); + return getSqlSourceKeys(fields, columns); + }; + + // A stable `combine` keeps the result referentially stable across renders + // until a query's data changes. + const combineKeys = useCallback( + (results: UseQueryResult[]) => { + const keysBySourceId = new Map(); + sources.forEach((source, index) => { + const keys = results[index]?.data; + if (keys) keysBySourceId.set(source.id, keys); + }); + return { + data: unionSorted(results.map(result => result.data)), + keysBySourceId, + isLoading: results.some(result => result.isLoading), + isError: results.some(result => result.isError), + }; + }, + [sources], + ); + + return useQueries({ + queries: sources.map( + (source): UseQueryOptions => ({ + queryKey: [ + 'adhoc-filter-keys', + source.id, + start, + end, + isFieldMetadataDisabled, + ], + queryFn: () => fetchKeys(source), + enabled: isMeFetched, + staleTime: STALE_TIME_MS, + placeholderData: keepPreviousData, + }), + ), + combine: combineKeys, + }); +} + +/** Values observed for `key` across an ad hoc filter's sources, deduplicated. */ +export function useAdhocFilterValues( + filter: AdhocDashboardFilter, + key: string, + dateRange: [Date, Date], + keysBySourceId: ReadonlyMap, +) { + const metadata = useMetadataWithSettings(); + const attribution = useMetadataQueryAttribution(); + const sources = useAdhocFilterSources(filter); + const { start, end } = toPromqlSeconds(dateRange); + + const sourcesWithKey = useMemo( + () => getSourcesWithKey(sources, keysBySourceId, key), + [sources, keysBySourceId, key], + ); + + const fetchValues = async ( + source: TSource, + signal: AbortSignal, + ): Promise => { + if (isPromqlSource(source)) { + return fetchPromqlLabelValues({ + label: key, + ...promqlLookupParams(source, dateRange, attribution), + }); + } + const keyValues = await metadata.getKeyValuesWithMVs({ + chartConfig: { + ...pickSourceConfigFields(source), + dateRange, + source: source.id, + select: '', + where: '', + whereLanguage: 'sql', + }, + keys: [key], + limit: VALUES_LIMIT, + source, + signal, + }); + return keyValues.flatMap(({ value }) => value.map(String)); + }; + + const combineValues = useCallback( + (results: UseQueryResult[]) => ({ + data: unionSorted(results.map(result => result.data)), + isLoading: !!key && results.some(result => result.isFetching), + isError: !!key && results.some(result => result.isError), + }), + [key], + ); + + return useQueries({ + queries: sourcesWithKey.map( + (source): UseQueryOptions => ({ + queryKey: ['adhoc-filter-values', source.id, key, start, end], + queryFn: ({ signal }) => fetchValues(source, signal), + enabled: !!key, + staleTime: STALE_TIME_MS, + retry: false, + }), + ), + combine: combineValues, + }); +} diff --git a/packages/app/src/components/DashboardFilterLabel.tsx b/packages/app/src/components/DashboardFilterLabel.tsx new file mode 100644 index 0000000000..509059dbfa --- /dev/null +++ b/packages/app/src/components/DashboardFilterLabel.tsx @@ -0,0 +1,58 @@ +import { ReactNode } from 'react'; +import { Group, Text, Tooltip } from '@mantine/core'; +import { IconAlertTriangle, IconHelp } from '@tabler/icons-react'; + +/** One of the caution icons a filter's label row can carry, with its tooltip. */ +export const FilterCaution = ({ + label, + testId, + variant = 'warning', +}: { + label: string; + testId: string; + variant?: 'warning' | 'danger'; +}) => ( + + + +); + +/** + * The row above a dashboard filter's input: its name, what it does, and any + * cautions passed as children. + */ +export const DashboardFilterLabel = ({ + name, + effect, + children, +}: { + name: string; + effect: { hasEffect: boolean; tooltip: string }; + children?: ReactNode; +}) => ( + + + {name} + + + {effect.hasEffect ? ( + + ) : ( + + )} + + {children} + +); diff --git a/packages/app/src/components/DashboardFiltersModal/DashboardFiltersList.tsx b/packages/app/src/components/DashboardFiltersModal/DashboardFiltersList.tsx index 5f92373c35..22e99756ed 100644 --- a/packages/app/src/components/DashboardFiltersModal/DashboardFiltersList.tsx +++ b/packages/app/src/components/DashboardFiltersModal/DashboardFiltersList.tsx @@ -1,9 +1,6 @@ import { - getAdhocFilterAppliesToSourceIds, getFilterBroadcastTarget, getFilterVariableName, - isAdhocFilter, - isFilterBroadcastEnabled, isFilterGlobalRequirement, isFilterRequired, isFilterVariableEnabled, @@ -104,12 +101,6 @@ function getBroadcastTargetDisplay( filter: DashboardFilter, sources?: { id: string; name: string }[], ) { - // An ad hoc filter broadcasts to its own sources unless narrowed further - if (isAdhocFilter(filter)) { - if (!isFilterBroadcastEnabled(filter)) return undefined; - return getSourceNames(getAdhocFilterAppliesToSourceIds(filter), sources); - } - const broadcastTarget = getFilterBroadcastTarget(filter); if (!broadcastTarget) return undefined; diff --git a/packages/app/src/components/FilterPill/FilterAutocomplete.tsx b/packages/app/src/components/FilterPill/FilterAutocomplete.tsx index 60e3a1c499..4a68d8aeb0 100644 --- a/packages/app/src/components/FilterPill/FilterAutocomplete.tsx +++ b/packages/app/src/components/FilterPill/FilterAutocomplete.tsx @@ -26,7 +26,7 @@ export type FilterAutocompleteProps = { 'aria-label'?: string; 'data-testid'?: string; ref?: Ref; -} & Pick; +} & Pick; /** Suggestion input for filter keys and values that also accepts free text. */ export function FilterAutocomplete({ diff --git a/packages/app/src/components/FilterPill/FilterConditionEditor.stories.tsx b/packages/app/src/components/FilterPill/FilterConditionEditor.stories.tsx index 7db25cea43..c0ff2ad772 100644 --- a/packages/app/src/components/FilterPill/FilterConditionEditor.stories.tsx +++ b/packages/app/src/components/FilterPill/FilterConditionEditor.stories.tsx @@ -112,3 +112,16 @@ export const LoadingValues = () => ( export const NoSuggestions = () => ( ); + +export const KeysError = () => ( + +); + +export const ValuesError = () => ( + +); diff --git a/packages/app/src/components/FilterPill/FilterConditionEditor.tsx b/packages/app/src/components/FilterPill/FilterConditionEditor.tsx index e5486e544a..fbee6097c6 100644 --- a/packages/app/src/components/FilterPill/FilterConditionEditor.tsx +++ b/packages/app/src/components/FilterPill/FilterConditionEditor.tsx @@ -26,9 +26,11 @@ export type FilterConditionEditorProps = { initial?: FilterCondition; keyOptions: string[]; isLoadingKeys?: boolean; + isKeysError?: boolean; /** Suggestions for the key last reported by `onKeyChange`. */ valueOptions: string[]; isLoadingValues?: boolean; + isValuesError?: boolean; /** The trimmed key, once typing pauses, so the caller can fetch its values. */ onKeyChange?: (key: string) => void; keyLabel?: string; @@ -38,6 +40,7 @@ export type FilterConditionEditorProps = { }; const KEY_DEBOUNCE_MS = 300; +const SUGGESTIONS_ERROR = "Couldn't load suggestions"; /** Pick a key, an operator, and a value. Free text is accepted for both inputs. */ export function FilterConditionEditor({ @@ -45,8 +48,10 @@ export function FilterConditionEditor({ initial, keyOptions, isLoadingKeys, + isKeysError, valueOptions, isLoadingValues, + isValuesError, onKeyChange, keyLabel = 'Key', keyPlaceholder = 'Select a key', @@ -69,6 +74,7 @@ export function FilterConditionEditor({ // Values shown while the key is still settling belong to the previous key. const isValueLoading = !!trimmedKey && (!!isLoadingValues || debouncedKey !== trimmedKey); + const isValuesErrorShown = !!trimmedKey && !isValueLoading && !!isValuesError; const selectedOperator = operators.find(op => op.value === operator); const canSubmit = !!trimmedKey && operator != null; @@ -84,6 +90,7 @@ export function FilterConditionEditor({ placeholder={keyPlaceholder} options={keyOptions} isLoading={isLoadingKeys} + error={!isLoadingKeys && isKeysError ? SUGGESTIONS_ERROR : undefined} value={key} onChange={setKey} // Enter moves on to the value instead of applying an empty-value filter. @@ -105,6 +112,7 @@ export function FilterConditionEditor({ placeholder={selectedOperator?.valuePlaceholder ?? 'Select a value'} options={valueOptions} isLoading={isValueLoading} + error={isValuesErrorShown ? SUGGESTIONS_ERROR : undefined} value={value} onChange={setValue} onSubmit={submit} diff --git a/packages/app/src/components/FilterPill/__tests__/FilterConditionEditor.test.tsx b/packages/app/src/components/FilterPill/__tests__/FilterConditionEditor.test.tsx index 7f5661d90e..ce82a1e90b 100644 --- a/packages/app/src/components/FilterPill/__tests__/FilterConditionEditor.test.tsx +++ b/packages/app/src/components/FilterPill/__tests__/FilterConditionEditor.test.tsx @@ -54,6 +54,20 @@ describe('FilterConditionEditor', () => { ).toBeInTheDocument(); }); + it('shows an error on the key input when keys fail to load', () => { + renderEditor({ isKeysError: true }); + expect(screen.getByText("Couldn't load suggestions")).toBeInTheDocument(); + }); + + it('shows a value error only once the key settles', async () => { + renderEditor({ isValuesError: true }); + expect(screen.queryByText("Couldn't load suggestions")).toBeNull(); + await userEvent.type(keyInput(), 'ServiceName'); + expect( + await screen.findByText("Couldn't load suggestions"), + ).toBeInTheDocument(); + }); + it('submits free-form text on Enter in the value input', async () => { const onSubmit = renderEditor({ isLoadingValues: true }); // `[[` types a literal `[`. diff --git a/packages/app/src/hooks/__tests__/useDashboardFilters.test.tsx b/packages/app/src/hooks/__tests__/useDashboardFilters.test.tsx index b3f9fffc39..50ee901750 100644 --- a/packages/app/src/hooks/__tests__/useDashboardFilters.test.tsx +++ b/packages/app/src/hooks/__tests__/useDashboardFilters.test.tsx @@ -1,4 +1,6 @@ import { + AdhocDashboardFilter, + AdhocFilterCondition, DashboardFilter, DashboardFilterValue, Filter, @@ -988,6 +990,107 @@ describe('useDashboardFilters', () => { }); }); + describe('ad hoc filters', () => { + const adhocFilter: AdhocDashboardFilter = { + id: 'adhoc1', + type: 'ADHOC', + name: 'Conditions', + sourceType: 'sql', + sources: ['logs'], + isVariableEnabled: true, + variableName: 'conds', + }; + const condition: AdhocFilterCondition = { + key: 'ServiceName', + operator: '!=', + value: 'api', + }; + + it('reads conditions from an ad hoc entry with its variable name', () => { + mockState = [{ type: 'adhoc', name: 'conds', conditions: [condition] }]; + + const { result } = renderHook(() => useDashboardFilters([adhocFilter])); + + expect(result.current.adhocConditionsByFilterId.get('adhoc1')).toEqual([ + condition, + ]); + expect(result.current.ignoredVariableNames).toEqual([]); + }); + + it('writes and clears conditions without touching other entries', () => { + mockState = [{ type: 'sql', condition: "environment IN ('prod')" }]; + const filters = [mockFilters[0], adhocFilter]; + const { result } = renderHook(() => useDashboardFilters(filters)); + + act(() => { + result.current.setAdhocConditions('adhoc1', [condition]); + }); + expect(mockState).toEqual([ + { type: 'sql', condition: "environment IN ('prod')" }, + { type: 'adhoc', name: 'conds', conditions: [condition] }, + ]); + + act(() => { + result.current.setAdhocConditions('adhoc1', []); + }); + expect(mockState).toEqual([ + { type: 'sql', condition: "environment IN ('prod')" }, + ]); + }); + + it('drops repeated conditions on read and write', () => { + mockState = [ + { type: 'adhoc', name: 'conds', conditions: [condition, condition] }, + ]; + const { result } = renderHook(() => useDashboardFilters([adhocFilter])); + + expect(result.current.adhocConditionsByFilterId.get('adhoc1')).toEqual([ + condition, + ]); + + act(() => { + result.current.setAdhocConditions('adhoc1', [condition, condition]); + }); + expect(mockState).toEqual([ + { type: 'adhoc', name: 'conds', conditions: [condition] }, + ]); + }); + + it('keeps conditions across a write to another filter', () => { + mockState = [{ type: 'adhoc', name: 'conds', conditions: [condition] }]; + const filters = [mockFilters[0], adhocFilter]; + const { result } = renderHook(() => useDashboardFilters(filters)); + + act(() => { + result.current.setFilterValue('filter1', ['prod']); + }); + + expect(mockState).toEqual([ + { type: 'sql', condition: "environment IN ('prod')" }, + { type: 'adhoc', name: 'conds', conditions: [condition] }, + ]); + }); + + it('exposes no variable, selection, or broadcast query', () => { + mockState = [{ type: 'adhoc', name: 'conds', conditions: [condition] }]; + + const { result } = renderHook(() => useDashboardFilters([adhocFilter])); + + expect(result.current.variables).toEqual([]); + expect(result.current.selectionByFilterId.size).toBe(0); + expect(result.current.broadcastedFilters).toEqual([]); + }); + + it('lists an ad hoc entry naming no declared filter as ignored', () => { + mockState = [{ type: 'adhoc', name: 'renamed', conditions: [condition] }]; + + const { result } = renderHook(() => useDashboardFilters([adhocFilter])); + + expect(result.current.adhocConditionsByFilterId.size).toBe(0); + expect(result.current.ignoredVariableNames).toEqual(['renamed']); + }); + }); + describe('unsatisfiedRequiredFilters', () => { const required = ( overrides: Partial = {}, diff --git a/packages/app/src/hooks/__tests__/usePresetDashboardFilters.test.tsx b/packages/app/src/hooks/__tests__/usePresetDashboardFilters.test.tsx index 2ccd430ffd..f3770007cc 100644 --- a/packages/app/src/hooks/__tests__/usePresetDashboardFilters.test.tsx +++ b/packages/app/src/hooks/__tests__/usePresetDashboardFilters.test.tsx @@ -111,6 +111,8 @@ describe('usePresetDashboardFilters', () => { jest.mocked(useDashboardFilters).mockReturnValue({ selectionByFilterId: mockSelectionByFilterId, setFilterValue: mockSetFilterValue, + adhocConditionsByFilterId: new Map(), + setAdhocConditions: jest.fn(), broadcastedFilters: mockFilterQueries, setFilterValueEntries: jest.fn(), filterValueEntries: null, diff --git a/packages/app/src/hooks/useDashboardFilters.tsx b/packages/app/src/hooks/useDashboardFilters.tsx index e103ab81e3..302a5a1264 100644 --- a/packages/app/src/hooks/useDashboardFilters.tsx +++ b/packages/app/src/hooks/useDashboardFilters.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo } from 'react'; +import { uniqBy } from 'lodash'; import { useQueryState } from 'nuqs'; import { FilterSelection, @@ -8,6 +9,7 @@ import { ParsedDashboardFilterValues, resolveFilterSelection, serializeDashboardFilterValues, + VariableEntryValue, } from '@hyperdx/common-utils/dist/dashboardFilterValues'; import { doesFilterApplyToSource, @@ -15,10 +17,13 @@ import { filtersToQuery, getDashboardVariableFilters, getFilterExpression, + getFilterVariableName, + isAdhocFilter, isFilterBroadcastEnabled, isQueryExpressionFilter, } from '@hyperdx/common-utils/dist/filters'; import { + AdhocFilterCondition, ChartVariable, DashboardFilter, Filter, @@ -39,6 +44,10 @@ const hasSelection = (selection: FilterSelection): boolean => const isRepresentableAsVariable = (selection: FilterSelection): boolean => selection.excluded.size === 0 && selection.range == null; +/** Conditions are identified by content (e.g. pill keys), so drop repeats. */ +const uniqConditions = (conditions: AdhocFilterCondition[]) => + uniqBy(conditions, condition => JSON.stringify(condition)); + /** * Rebuild the whole state array from the selections every declared filter * currently resolves to. Anything that belongs to no declared filter is @@ -49,11 +58,12 @@ const rebuildEntries = ( parsed: ParsedDashboardFilterValues, ) => { const byExpression: FilterState = {}; - const byVariable = new Map(); + const byVariable = new Map(); const declaredExpressions = new Set(); const declaredVariableNames = new Set(); for (const filter of filters) { + if (isAdhocFilter(filter)) continue; const expression = getFilterExpression(filter); if (expression) declaredExpressions.add(expression); @@ -67,7 +77,10 @@ const rebuildEntries = ( if (!isRepresentableAsVariable(selection) && expression) { byExpression[expression] = selection; } else if (selection.included.size > 0) { - byVariable.set(key.name, Array.from(selection.included).map(String)); + byVariable.set(key.name, { + type: 'variable', + values: Array.from(selection.included).map(String), + }); } } @@ -83,9 +96,10 @@ const rebuildEntries = ( } } - // Pass through any entries that don't correspond to a declared filter. - for (const [name, values] of parsed.byVariable) { - if (!declaredVariableNames.has(name)) byVariable.set(name, values); + // Pass through any entries that don't correspond to a declared filter. Ad hoc + // filters are skipped above, so their conditions pass through here too. + for (const [name, value] of parsed.byVariable) { + if (!declaredVariableNames.has(name)) byVariable.set(name, value); } for (const [expression, selection] of Object.entries(parsed.byExpression)) { if (!declaredExpressions.has(expression)) { @@ -120,7 +134,7 @@ const useDashboardFilters = (filters: DashboardFilter[]) => { const key = filterSelectionKey(target); if (key.kind === 'variable') { // Set even when empty to avoid falling back to a legacy expression-keyed value - parsed.byVariable.set(key.name, values); + parsed.byVariable.set(key.name, { type: 'variable', values }); } else if (values.length === 0) { delete parsed.byExpression[key.expression]; } else { @@ -136,28 +150,62 @@ const useDashboardFilters = (filters: DashboardFilter[]) => { [setFilterValueEntries, filters], ); + const setAdhocConditions = useCallback( + (filterId: string, conditions: AdhocFilterCondition[]) => { + setFilterValueEntries(prev => { + const target = filters.find(f => f.id === filterId); + const name = target && getFilterVariableName(target); + if (!name) return prev; + + const parsed = parseDashboardFilterValues(prev ?? []); + parsed.byVariable.set(name, { + type: 'adhoc', + conditions: uniqConditions(conditions), + }); + return rebuildEntries(filters, parsed); + }); + }, + [setFilterValueEntries, filters], + ); + const { selectionByFilterId, + adhocConditionsByFilterId, ignoredExpressions, ignoredVariableNames, variables, unsatisfiedRequiredFilters, } = useMemo<{ selectionByFilterId: ReadonlyMap; + adhocConditionsByFilterId: ReadonlyMap; ignoredExpressions: string[]; ignoredVariableNames: string[]; variables: ChartVariable[]; unsatisfiedRequiredFilters: DashboardFilter[]; }>(() => { const parsed = parseDashboardFilterValues(filterValueEntries ?? []); + // Ad hoc filters aren't exposed as variables yet + const nonAdhocFilters = filters.filter(filter => !isAdhocFilter(filter)); + const adhocFilters = filters.filter(isAdhocFilter); // Keyed by filter ID: two definitions may share one expression const selectionByFilterId = new Map(); - for (const filter of filters) { + for (const filter of nonAdhocFilters) { const selection = resolveFilterSelection(filter, parsed); if (selection) selectionByFilterId.set(filter.id, selection); } + const adhocConditionsByFilterId = new Map(); + for (const filter of adhocFilters) { + const value = parsed.byVariable.get(getFilterVariableName(filter) ?? ''); + if (value?.type === 'adhoc') { + adhocConditionsByFilterId.set( + filter.id, + uniqConditions(value.conditions), + ); + } + } + // Find state that doesn't correspond to any declared filter, so the caller can surface a warning. const knownExpressions = new Set( filters.filter(isQueryExpressionFilter).map(f => f.expression), @@ -166,8 +214,11 @@ const useDashboardFilters = (filters: DashboardFilter[]) => { expression => !knownExpressions.has(expression), ); - const variableFilters = getDashboardVariableFilters(filters); - const declaredVariableNames = new Set(variableFilters.map(v => v.name)); + const variableFilters = getDashboardVariableFilters(nonAdhocFilters); + const declaredVariableNames = new Set([ + ...variableFilters.map(v => v.name), + ...adhocFilters.flatMap(filter => getFilterVariableName(filter) ?? []), + ]); const ignoredVariableNames = Array.from(parsed.byVariable.keys()).filter( name => !declaredVariableNames.has(name), ); @@ -191,6 +242,7 @@ const useDashboardFilters = (filters: DashboardFilter[]) => { return { selectionByFilterId, + adhocConditionsByFilterId, variables, ignoredExpressions, ignoredVariableNames, @@ -260,6 +312,10 @@ const useDashboardFilters = (filters: DashboardFilter[]) => { broadcastedFilters, /** Set the selected values for a filter, by its ID */ setFilterValue, + /** Each ad hoc filter's current conditions, keyed by `filter.id`. */ + adhocConditionsByFilterId, + /** Replace the conditions of an ad hoc filter, by its ID */ + setAdhocConditions, /** Set the raw filter value state */ setFilterValueEntries, /** The raw persisted entries, as they appear in the URL param. */ diff --git a/packages/app/src/hooks/usePromqlLabelFilterValues.tsx b/packages/app/src/hooks/usePromqlLabelFilterValues.tsx index 03b2a9c278..fd7e56177a 100644 --- a/packages/app/src/hooks/usePromqlLabelFilterValues.tsx +++ b/packages/app/src/hooks/usePromqlLabelFilterValues.tsx @@ -14,7 +14,10 @@ import { UseQueryResult, } from '@tanstack/react-query'; -import { prometheusApi } from '@/api'; +import { + fetchPromqlLabelValues, + toPromqlSeconds, +} from '@/hooks/usePromqlMetadata'; import { useMetadataQueryAttribution } from '@/queryAttribution'; import { useSources } from '@/source'; import { mapKeyBy } from '@/utils'; @@ -44,9 +47,7 @@ export function usePromqlLabelFilterValues({ const { data: sources, isLoading: isLoadingSources } = useSources(); const sourcesById = useMemo(() => mapKeyBy(sources ?? [], 'id'), [sources]); - // Round the date range, since the API accepts whole seconds - const startSec = Math.floor(dateRange[0].getTime() / 1000); - const endSec = Math.ceil(dateRange[1].getTime() / 1000); + const { start: startSec, end: endSec } = toPromqlSeconds(dateRange); // A filter's selector may reference the dashboard's variables. Expand them // here so react-query keys on the resolved selector rather than the template. @@ -119,8 +120,8 @@ export function usePromqlLabelFilterValues({ return cached[0]?.data; }, staleTime: 1000 * 60 * 5, - queryFn: async (): Promise => { - const resp = await prometheusApi.labelValues({ + queryFn: () => + fetchPromqlLabelValues({ label: call.label, connectionId: call.connectionId, database: call.database, @@ -129,12 +130,7 @@ export function usePromqlLabelFilterValues({ end: endSec, match: call.match, attribution, - }); - if (resp.status === 'error') { - throw new Error(resp.error ?? 'Label values query failed'); - } - return resp.data ?? []; - }, + }), }; }), }); diff --git a/packages/app/src/hooks/usePromqlMetadata.ts b/packages/app/src/hooks/usePromqlMetadata.ts index 351cc7632c..9dc59dc5ba 100644 --- a/packages/app/src/hooks/usePromqlMetadata.ts +++ b/packages/app/src/hooks/usePromqlMetadata.ts @@ -1,8 +1,44 @@ +import { QueryAttribution } from '@hyperdx/common-utils/dist/clickhouse'; import { useQuery } from '@tanstack/react-query'; import { prometheusApi } from '@/api'; import { useMetadataQueryAttribution } from '@/queryAttribution'; +type PromqlLabelLookup = { + connectionId: string; + database?: string; + table?: string; + start?: number; + end?: number; + attribution?: QueryAttribution; +}; + +/** The label lookup APIs accept whole seconds. */ +export const toPromqlSeconds = (dateRange: [Date, Date]) => ({ + start: Math.floor(dateRange[0].getTime() / 1000), + end: Math.ceil(dateRange[1].getTime() / 1000), +}); + +export async function fetchPromqlLabelNames( + params: PromqlLabelLookup, +): Promise { + const resp = await prometheusApi.labels(params); + if (resp.status === 'error') { + throw new Error(resp.error ?? 'Label names query failed'); + } + return resp.data ?? []; +} + +export async function fetchPromqlLabelValues( + params: PromqlLabelLookup & { label: string; match?: string }, +): Promise { + const resp = await prometheusApi.labelValues(params); + if (resp.status === 'error') { + throw new Error(resp.error ?? 'Label values query failed'); + } + return resp.data ?? []; +} + export function usePromqlMetricNames( connectionId: string | undefined, database?: string, @@ -13,14 +49,13 @@ export function usePromqlMetricNames( queryKey: ['promql-metric-names', connectionId, database, table], queryFn: async () => { if (!connectionId) return []; - const resp = await prometheusApi.labelValues({ + return fetchPromqlLabelValues({ label: '__name__', connectionId, database, table, attribution, }); - return resp.data ?? []; }, enabled: !!connectionId, staleTime: 60_000, @@ -38,13 +73,12 @@ export function usePromqlLabelNames( queryKey: ['promql-label-names', connectionId, database, table], queryFn: async () => { if (!connectionId) return []; - const resp = await prometheusApi.labels({ + return fetchPromqlLabelNames({ connectionId, database, table, attribution, }); - return resp.data ?? []; }, enabled: !!connectionId, staleTime: 60_000, diff --git a/packages/app/tests/e2e/features/dashboard-adhoc-filters.spec.ts b/packages/app/tests/e2e/features/dashboard-adhoc-filters.spec.ts index e64ed257cc..11504ed5f2 100644 --- a/packages/app/tests/e2e/features/dashboard-adhoc-filters.spec.ts +++ b/packages/app/tests/e2e/features/dashboard-adhoc-filters.spec.ts @@ -1,9 +1,11 @@ /** * An `ADHOC` dashboard filter's conditions (`key operator value`) are picked - * from the keys of its sources. For now the filters modal only lists, creates, - * and edits them; they are not rendered or applied on the dashboard. + * from the keys of its sources. The filters modal creates and edits them, and + * the dashboard renders their conditions and keeps them in the URL; they are not + * applied to tiles yet. */ import { DashboardPage } from '../page-objects/DashboardPage'; +import { SERVICES } from '../seed-clickhouse'; import { expect, test } from '../utils/base-test'; import { DEFAULT_LOGS_SOURCE_NAME, @@ -11,6 +13,7 @@ import { DEFAULT_TRACES_SOURCE_NAME, PROMQL_SOURCE_NAME, } from '../utils/constants'; +import { expectFiltersParam, filtersParam } from '../utils/filters-param'; test.describe( 'Ad hoc dashboard filters', @@ -257,6 +260,213 @@ test.describe( ).toBeVisible(); }); + test('picks conditions from the keys and values of one SQL source', async ({ + page, + }) => { + const filterName = 'Conditions'; + const pills = dashboardPage.getAdhocConditionPills(filterName); + + await test.step('Create the filter', async () => { + await dashboardPage.createNewDashboard(); + await dashboardPage.openEditFiltersModal(); + await dashboardPage.addAdhocFilterToDashboard( + filterName, + [DEFAULT_LOGS_SOURCE_NAME], + { variableName: 'conds' }, + ); + await dashboardPage.closeFiltersModal(); + await expect(dashboardPage.getAdhocFilter(filterName)).toBeVisible(); + }); + + await test.step('Keys are suggested, values only once a key is picked', async () => { + const editor = dashboardPage.getAdhocConditionEditor(filterName); + const valueInput = dashboardPage.getAdhocConditionInput( + filterName, + 'value', + ); + const serviceNameOption = editor.getByRole('option', { + name: 'ServiceName', + exact: true, + }); + const accountingOption = editor.getByRole('option', { + name: 'accounting', + exact: true, + }); + + await dashboardPage.openAddAdhocCondition(filterName); + await valueInput.click(); + await expect(editor.getByRole('option')).toHaveCount(0); + + await dashboardPage.getAdhocConditionInput(filterName, 'key').click(); + await expect(serviceNameOption).toBeVisible({ timeout: 30000 }); + await serviceNameOption.click(); + + await valueInput.click(); + await expect(accountingOption).toBeVisible({ timeout: 30000 }); + await accountingOption.click(); + await valueInput.press('Enter'); + await expect(editor).toBeHidden(); + }); + + await test.step('The condition is shown and kept in the URL', async () => { + await expect(pills).toHaveCount(1); + await expect(pills).toHaveText('ServiceName = accounting'); + await expectFiltersParam(page, [ + { + type: 'adhoc', + name: 'conds', + conditions: [ + { key: 'ServiceName', operator: '=', value: 'accounting' }, + ], + }, + ]); + }); + + await test.step('Editing the pill changes its operator', async () => { + await dashboardPage.editAdhocCondition( + filterName, + 'ServiceName = accounting', + { operatorLabel: '!=' }, + ); + await expect(pills).toHaveText('ServiceName != accounting'); + await expectFiltersParam(page, [ + { + type: 'adhoc', + name: 'conds', + conditions: [ + { key: 'ServiceName', operator: '!=', value: 'accounting' }, + ], + }, + ]); + }); + + await test.step('The condition survives a reload', async () => { + await page.reload(); + await dashboardPage.waitForLoaded(); + await expect(pills).toHaveText('ServiceName != accounting'); + }); + + await test.step('Removing the pill clears it from the URL', async () => { + await pills.getByRole('button', { name: 'Remove filter' }).click(); + await expect(pills).toHaveCount(0); + await expect.poll(() => filtersParam(page) ?? []).toEqual([]); + }); + }); + + test('offers deduplicated keys and values across several SQL sources', async ({ + page, + }) => { + const filterName = 'Both'; + const editor = dashboardPage.getAdhocConditionEditor(filterName); + const pills = dashboardPage.getAdhocConditionPills(filterName); + + await test.step('Create a filter over logs and traces', async () => { + await dashboardPage.createNewDashboard(); + await dashboardPage.openEditFiltersModal(); + await dashboardPage.addAdhocFilterToDashboard( + filterName, + [DEFAULT_LOGS_SOURCE_NAME, DEFAULT_TRACES_SOURCE_NAME], + { variableName: 'both' }, + ); + await dashboardPage.closeFiltersModal(); + }); + + const keyInput = dashboardPage.getAdhocConditionInput(filterName, 'key'); + + await test.step('A key both sources have is listed once', async () => { + await dashboardPage.openAddAdhocCondition(filterName); + // Typing narrows the list, which renders at most 200 suggestions. + await keyInput.fill('ServiceName'); + await expect( + editor.getByRole('option', { name: 'ServiceName', exact: true }), + ).toHaveCount(1, { timeout: 30000 }); + }); + + await test.step('A key only one source has is offered too', async () => { + await keyInput.fill('SpanName'); + await expect( + editor.getByRole('option', { name: 'SpanName', exact: true }), + ).toHaveCount(1); + }); + + await test.step("The key's values are listed once each", async () => { + await keyInput.fill('ServiceName'); + await dashboardPage.getAdhocConditionInput(filterName, 'value').click(); + await expect( + editor.getByRole('option', { name: 'accounting', exact: true }), + ).toHaveCount(1, { timeout: 30000 }); + const options = await dashboardPage.getOpenFilterDropdownOptions(); + expect(new Set(options).size).toBe(options.length); + expect(options).toEqual(expect.arrayContaining([...SERVICES])); + await dashboardPage.fillAdhocConditionEditor(filterName, { + value: 'accounting', + }); + }); + + await test.step('A key only one source has gets its values', async () => { + await dashboardPage.openAddAdhocCondition(filterName); + await keyInput.fill('SeverityText'); + await dashboardPage.getAdhocConditionInput(filterName, 'value').click(); + await expect( + editor.getByRole('option', { name: 'error', exact: true }), + ).toBeVisible({ timeout: 30000 }); + await dashboardPage.fillAdhocConditionEditor(filterName, { + value: 'error', + }); + }); + + await test.step('Both conditions survive a reload', async () => { + await expect(pills).toHaveText([ + 'ServiceName = accounting', + 'SeverityText = error', + ]); + await page.reload(); + await dashboardPage.waitForLoaded(); + await expect(pills).toHaveText([ + 'ServiceName = accounting', + 'SeverityText = error', + ]); + }); + }); + + test('picks conditions from the labels of a PromQL source', async () => { + const filterName = 'Labels'; + const editor = dashboardPage.getAdhocConditionEditor(filterName); + + await dashboardPage.createNewDashboard(); + await dashboardPage.openEditFiltersModal(); + await dashboardPage.addAdhocFilterToDashboard( + filterName, + [PROMQL_SOURCE_NAME], + { sourceType: 'Prometheus' }, + ); + await dashboardPage.closeFiltersModal(); + + await dashboardPage.openAddAdhocCondition(filterName); + await dashboardPage.getAdhocConditionInput(filterName, 'key').click(); + await expect( + editor.getByRole('option', { name: 'service', exact: true }), + ).toBeVisible({ timeout: 30000 }); + await expect( + editor.getByRole('option', { name: '__name__', exact: true }), + ).toHaveCount(0); + + await dashboardPage + .getAdhocConditionInput(filterName, 'key') + .fill('service'); + await dashboardPage.getAdhocConditionInput(filterName, 'value').click(); + await expect( + editor.getByRole('option', { name: 'accounting', exact: true }), + ).toBeVisible({ timeout: 30000 }); + await dashboardPage.fillAdhocConditionEditor(filterName, { + operatorLabel: '=~', + value: 'acc.*', + }); + await expect(dashboardPage.getAdhocConditionPills(filterName)).toHaveText( + 'service =~ acc.*', + ); + }); + test("lives alongside the dashboard's other filters", async ({ page }) => { await test.step('Create an ad hoc and a static filter', async () => { await dashboardPage.createNewDashboard(); @@ -285,11 +495,12 @@ test.describe( await page.getByRole('button', { name: 'Cancel' }).click(); }); - await test.step('Only the other filter is rendered on the dashboard', async () => { + await test.step('Both filters are rendered on the dashboard', async () => { await dashboardPage.closeFiltersModal(); await expect( dashboardPage.getFilterSelectByName('Environment'), ).toBeVisible(); + await expect(dashboardPage.getAdhocFilter('Conditions')).toBeVisible(); await expect( dashboardPage.getFilterSelectByName('Conditions'), ).toHaveCount(0); diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts index 974646c7d1..140e5a0294 100644 --- a/packages/app/tests/e2e/page-objects/DashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts @@ -1569,11 +1569,18 @@ export class DashboardPage { async addAdhocFilterToDashboard( name: string, sourceNames: string[], - options?: { variableName?: string }, + options?: { + variableName?: string; + sourceType?: 'ClickHouse' | 'Prometheus'; + }, ) { await this.addFiltersButton.click(); await this.selectFilterType('Ad hoc keys and values'); await this.getFilterNameInput().fill(name); + // Before the sources, since switching the type clears them. + if (options?.sourceType) { + await this.selectAdhocFilterSourceType(options.sourceType); + } await this.selectSourcesInMultiSelect( this.getAdhocFilterSourcesInput(), sourceNames, @@ -1588,6 +1595,89 @@ export class DashboardPage { }); } + /** The filter bar control of an ad hoc filter. */ + getAdhocFilter(filterName: string): Locator { + return this.page.getByTestId(`adhoc-filter-${filterName}`); + } + + /** The condition pills of an ad hoc filter in the filter bar. */ + getAdhocConditionPills(filterName: string): Locator { + return this.getAdhocFilter(filterName).getByTestId('adhoc-condition-pill'); + } + + /** The open condition editor of an ad hoc filter. */ + getAdhocConditionEditor(filterName: string): Locator { + return this.page.getByTestId(`adhoc-condition-editor-${filterName}`); + } + + /** The key, operator, or value input of an ad hoc filter's open editor. */ + getAdhocConditionInput( + filterName: string, + input: 'key' | 'operator' | 'value', + ): Locator { + return this.getAdhocConditionEditor(filterName).getByTestId( + `adhoc-condition-editor-${filterName}-${input}`, + ); + } + + /** Open the editor that adds a condition to an ad hoc filter. */ + async openAddAdhocCondition(filterName: string) { + await this.page.getByTestId(`adhoc-filter-add-${filterName}`).click(); + await this.getAdhocConditionEditor(filterName).waitFor({ + state: 'visible', + }); + } + + /** Add a `key operator value` condition through the filter bar editor. */ + async addAdhocCondition( + filterName: string, + condition: { key: string; operatorLabel?: string; value: string }, + ) { + await this.openAddAdhocCondition(filterName); + await this.fillAdhocConditionEditor(filterName, condition); + } + + /** Click a condition's pill and change any part of it in the editor. */ + async editAdhocCondition( + filterName: string, + pillText: string, + changes: { key?: string; operatorLabel?: string; value?: string }, + ) { + await this.getAdhocConditionPills(filterName) + .filter({ hasText: pillText }) + .click(); + await this.fillAdhocConditionEditor(filterName, changes); + } + + /** Fill the open editor's inputs, then apply it. */ + async fillAdhocConditionEditor( + filterName: string, + { + key, + operatorLabel, + value, + }: { key?: string; operatorLabel?: string; value?: string }, + ) { + const editor = this.getAdhocConditionEditor(filterName); + if (key !== undefined) { + await this.getAdhocConditionInput(filterName, 'key').fill(key); + } + if (operatorLabel !== undefined) { + await this.getAdhocConditionInput(filterName, 'operator').click(); + await editor + .getByRole('option', { name: operatorLabel, exact: true }) + .click(); + } + const valueInput = this.getAdhocConditionInput(filterName, 'value'); + if (value !== undefined) { + await valueInput.fill(value); + } + // Enter applies. Escape would close the whole popover, and the value + // suggestions can cover the Apply button. + await valueInput.press('Enter'); + await editor.waitFor({ state: 'hidden' }); + } + /** * The options offered by the dashboard filter dropdown that is currently * open, in render order. Scoped to visible nodes: a just-closed dropdown's diff --git a/packages/app/tests/e2e/utils/filters-param.ts b/packages/app/tests/e2e/utils/filters-param.ts index 17a26c7851..a131150672 100644 --- a/packages/app/tests/e2e/utils/filters-param.ts +++ b/packages/app/tests/e2e/utils/filters-param.ts @@ -1,13 +1,19 @@ /** * The `filters=` URL param carries dashboard filter selections in one of two - * shapes: legacy, keyed by SQL expression, or variable-keyed by variable name. - * These helpers are the one definition of that contract for the E2E suite. + * shapes: legacy, keyed by SQL expression, or variable-keyed by variable name + * (selected values, or an ad hoc filter's conditions). These helpers are the + * one definition of that contract for the E2E suite. */ import { expect, type Page } from '@playwright/test'; export type FilterEntry = | { type: 'sql'; condition: string } - | { type: 'variable'; name: string; values: string[] }; + | { type: 'variable'; name: string; values: string[] } + | { + type: 'adhoc'; + name: string; + conditions: { key: string; operator: string; value: string }[]; + }; /** The raw, still-encoded `filters=` param. `null` when the param is absent. */ export const rawFiltersParam = (page: Page): string | null => diff --git a/packages/common-utils/src/__tests__/dashboardFilterValues.test.ts b/packages/common-utils/src/__tests__/dashboardFilterValues.test.ts index f90a424e11..ab54c33ba9 100644 --- a/packages/common-utils/src/__tests__/dashboardFilterValues.test.ts +++ b/packages/common-utils/src/__tests__/dashboardFilterValues.test.ts @@ -7,9 +7,11 @@ import { parseDashboardFilterValues, resolveFilterSelection, serializeDashboardFilterValues, + VariableEntryValue, } from '@/dashboardFilterValues'; import { FilterState, filtersToQuery } from '@/filters'; import type { + AdhocFilterCondition, ChartConfigWithOptDateRange, DashboardFilter, DashboardFilterValue, @@ -56,6 +58,17 @@ const promqlFilter = ( ...overrides, }); +const condition: AdhocFilterCondition = { + key: 'ServiceName', + operator: '=', + value: 'api', +}; + +const variableValue = (...values: string[]): VariableEntryValue => ({ + type: 'variable', + values, +}); + const included = (...values: (string | boolean)[]) => ({ included: new Set(values), excluded: new Set(), @@ -79,7 +92,9 @@ describe('dashboardFilterValues', () => { ]); expect(parsed.byExpression).toEqual({ Env: included('prod') }); - expect(Array.from(parsed.byVariable)).toEqual([['svc', ['accounting']]]); + expect(Array.from(parsed.byVariable)).toEqual([ + ['svc', variableValue('accounting')], + ]); expect(parsed.passthrough).toEqual([]); }); @@ -91,8 +106,8 @@ describe('dashboardFilterValues', () => { expect(parsed.byExpression).toEqual({}); expect(Array.from(parsed.byVariable)).toEqual([ - ['svc', ['a', 'b']], - ['env', []], + ['svc', variableValue('a', 'b')], + ['env', variableValue()], ]); }); @@ -114,7 +129,33 @@ describe('dashboardFilterValues', () => { { type: 'variable', name: 'svc', values: ['second'] }, ]); - expect(Array.from(parsed.byVariable)).toEqual([['svc', ['first']]]); + expect(Array.from(parsed.byVariable)).toEqual([ + ['svc', variableValue('first')], + ]); + }); + + it('keys ad hoc entries by name alongside variable entries', () => { + const parsed = parseDashboardFilterValues([ + { type: 'variable', name: 'svc', values: ['a'] }, + { type: 'adhoc', name: 'conds', conditions: [condition] }, + ]); + + expect(Array.from(parsed.byVariable)).toEqual([ + ['svc', variableValue('a')], + ['conds', { type: 'adhoc', conditions: [condition] }], + ]); + expect(parsed.passthrough).toEqual([]); + }); + + it('keeps the first entry for a name across variable and ad hoc entries', () => { + const parsed = parseDashboardFilterValues([ + { type: 'adhoc', name: 'svc', conditions: [condition] }, + { type: 'variable', name: 'svc', values: ['second'] }, + ]); + + expect(Array.from(parsed.byVariable)).toEqual([ + ['svc', { type: 'adhoc', conditions: [condition] }], + ]); }); it('routes non-sql entries to passthrough', () => { @@ -152,8 +193,8 @@ describe('dashboardFilterValues', () => { ]); expect(serializeDashboardFilterValues(parsed)).toEqual([ - { type: 'variable', name: 'env', values: ['prod'] }, adhoc, + { type: 'variable', name: 'env', values: ['prod'] }, ]); }); @@ -189,8 +230,8 @@ describe('dashboardFilterValues', () => { serializeDashboardFilterValues({ byExpression: { Env: included('prod'), Region: included('us') }, byVariable: new Map([ - ['svc', ['accounting']], - ['team', ['platform']], + ['svc', variableValue('accounting')], + ['team', variableValue('platform')], ]), passthrough: [passthrough], }), @@ -203,6 +244,21 @@ describe('dashboardFilterValues', () => { ]); }); + it('round-trips ad hoc entries and omits empty ones', () => { + const entries = serializeDashboardFilterValues({ + byVariable: new Map([ + ['conds', { type: 'adhoc', conditions: [condition] }], + ['empty', { type: 'adhoc', conditions: [] }], + ]), + }); + expect(entries).toEqual([ + { type: 'adhoc', name: 'conds', conditions: [condition] }, + ]); + expect( + serializeDashboardFilterValues(parseDashboardFilterValues(entries)), + ).toEqual(entries); + }); + it('omits empty selections from both schemes', () => { expect( serializeDashboardFilterValues({ @@ -211,8 +267,8 @@ describe('dashboardFilterValues', () => { Region: included('us'), }, byVariable: new Map([ - ['svc', []], - ['team', ['platform']], + ['svc', variableValue()], + ['team', variableValue('platform')], ]), }), ).toEqual([ @@ -264,7 +320,7 @@ describe('dashboardFilterValues', () => { 'preserves %s exactly on the variable path', (_label, value) => { const entries = serializeDashboardFilterValues({ - byVariable: new Map([['svc', [value]]]), + byVariable: new Map([['svc', variableValue(value)]]), }); expect(entries).toEqual([ { type: 'variable', name: 'svc', values: [value] }, @@ -275,7 +331,7 @@ describe('dashboardFilterValues', () => { const parsed = parseDashboardFilterValues( JSON.parse(JSON.stringify(entries)), ); - expect(parsed.byVariable.get('svc')).toEqual([value]); + expect(parsed.byVariable.get('svc')).toEqual(variableValue(value)); expect(parsed.byExpression).toEqual({}); expect(parsed.passthrough).toEqual([]); }, @@ -319,7 +375,7 @@ describe('dashboardFilterValues', () => { ]); expect( serializeDashboardFilterValues({ - byVariable: new Map([['svc', ['true']]]), + byVariable: new Map([['svc', variableValue('true')]]), }), ).toEqual([{ type: 'variable', name: 'svc', values: ['true'] }]); }); @@ -430,6 +486,14 @@ describe('dashboardFilterValues', () => { ); }); + it('does not read an ad hoc entry as a selection', () => { + const parsed = parseDashboardFilterValues([ + { type: 'adhoc', name: 'svc', conditions: [condition] }, + ]); + + expect(resolveFilterSelection(variableFilter, parsed)).toBeUndefined(); + }); + it('falls back to the expression entry for back-compat', () => { const parsed = parseDashboardFilterValues([ { type: 'sql', condition: "ServiceName IN ('legacy')" }, diff --git a/packages/common-utils/src/__tests__/filters.test.ts b/packages/common-utils/src/__tests__/filters.test.ts index aff7b0326d..35080f35e6 100644 --- a/packages/common-utils/src/__tests__/filters.test.ts +++ b/packages/common-utils/src/__tests__/filters.test.ts @@ -28,6 +28,7 @@ import { validateVariableName, } from '@/filters'; import type { + AdhocDashboardFilter, ChartVariable, DashboardFilter, Filter, @@ -1611,14 +1612,33 @@ describe('filters', () => { ...overrides, }); - it('reports the expression and scope for a broadcasting filter', () => { + it('reports the scope for a broadcasting filter', () => { expect(getFilterBroadcastTarget(filter({}))).toEqual({ - expression: 'ServiceName', appliesToSourceIds: undefined, }); expect( getFilterBroadcastTarget(filter({ appliesToSourceIds: ['logs'] })), - ).toEqual({ expression: 'ServiceName', appliesToSourceIds: ['logs'] }); + ).toEqual({ appliesToSourceIds: ['logs'] }); + }); + + it('scopes an ad hoc filter to its own sources unless narrowed', () => { + const adhoc: AdhocDashboardFilter = { + id: 'f4', + type: 'ADHOC', + name: 'Conditions', + sourceType: 'sql', + sources: ['logs', 'traces'], + isVariableEnabled: true, + }; + expect(getFilterBroadcastTarget(adhoc)).toEqual({ + appliesToSourceIds: ['logs', 'traces'], + }); + expect( + getFilterBroadcastTarget({ ...adhoc, appliesToSourceIds: ['logs'] }), + ).toEqual({ appliesToSourceIds: ['logs'] }); + expect( + getFilterBroadcastTarget({ ...adhoc, isBroadcastEnabled: false }), + ).toBeUndefined(); }); it('returns undefined when broadcasting is off', () => { diff --git a/packages/common-utils/src/dashboardFilterValues.ts b/packages/common-utils/src/dashboardFilterValues.ts index f060de0ee3..33b5f6180f 100644 --- a/packages/common-utils/src/dashboardFilterValues.ts +++ b/packages/common-utils/src/dashboardFilterValues.ts @@ -5,6 +5,7 @@ import { getDashboardVariableFilters, getFilterExpression, getFilterVariableName, + isAdhocFilterValue, isFilterGlobalRequirement, isFilterRequired, isFilterVariableEnabled, @@ -12,6 +13,7 @@ import { } from '@/filters'; import { isMissingFiltersMacro } from '@/macros'; import { + AdhocFilterValue, ChartConfigWithOptDateRange, DashboardFilter, DashboardFilterValue, @@ -27,6 +29,7 @@ import { isFilterSingleSelect } from '@/variables'; * * - legacy, keyed by SQL expression: `{ type: 'sql', condition: "E IN ('a')" }` * - variable-keyed: `{ type: 'variable', name: 'svc', values: ['a'] }` + * `{ type: 'adhoc', name: 'conds', conditions: [...] }` * * Expression keying cannot represent two filters that share an `expression`, * and has nowhere to put a selection for a filter with no expression at all. @@ -39,11 +42,16 @@ import { isFilterSingleSelect } from '@/variables'; /** One filter's selection: what a `FilterState` holds per key. */ export type FilterSelection = FilterState[string]; +/** A variable-keyed entry, without the name it is keyed by. */ +export type VariableEntryValue = + | Omit + | Omit; + export type ParsedDashboardFilterValues = { /** Selections addressed by SQL expression, parsed via `parseQuery`. */ byExpression: FilterState; - /** Selections addressed by dashboard variable name. */ - byVariable: Map; + /** Selected values and ad hoc conditions addressed by dashboard variable name. */ + byVariable: Map; /** * Non-`sql` entries (`lucene`, `sql_ast`), carried verbatim so a write doesn't * destroy them. @@ -59,15 +67,16 @@ const isVariableEntry = ( export function parseDashboardFilterValues( entries: DashboardFilterValue[] | undefined, ): ParsedDashboardFilterValues { - const byVariable = new Map(); + const byVariable = new Map(); const passthrough: DashboardFilterValue[] = []; const sqlEntries: Filter[] = []; for (const entry of entries ?? []) { - if (isVariableEntry(entry)) { + if (isVariableEntry(entry) || isAdhocFilterValue(entry)) { // There shouldn't be duplicate names, but if there are the first wins. if (!byVariable.has(entry.name)) { - byVariable.set(entry.name, entry.values); + const { name, ...value } = entry; + byVariable.set(name, value); } continue; } @@ -99,7 +108,7 @@ export function parseDashboardFilterValues( */ export function serializeDashboardFilterValues(input: { byExpression?: FilterState; - byVariable?: ReadonlyMap; + byVariable?: ReadonlyMap; passthrough?: DashboardFilterValue[]; }): DashboardFilterValue[] { const entries: DashboardFilterValue[] = [ @@ -107,9 +116,13 @@ export function serializeDashboardFilterValues(input: { ...filtersToQuery(input.byExpression ?? {}, { stringifyKeys: false }), ]; - for (const [name, values] of input.byVariable ?? []) { - if (values.length === 0) continue; // Empty selections are omitted. - entries.push({ type: 'variable', name, values }); + for (const [name, value] of input.byVariable ?? []) { + // Empty selections are omitted. + const isEmpty = + value.type === 'adhoc' + ? value.conditions.length === 0 + : value.values.length === 0; + if (!isEmpty) entries.push({ name, ...value }); } entries.push(...(input.passthrough ?? [])); @@ -149,7 +162,7 @@ export function filterSelectionKey( export function resolveFilterSelection( filter: DashboardFilter, parsed: Pick & { - byVariable: ReadonlyMap; + byVariable: ReadonlyMap; }, ): FilterSelection | undefined { const selection = resolveUncappedFilterSelection(filter, parsed); @@ -165,15 +178,15 @@ export function resolveFilterSelection( function resolveUncappedFilterSelection( filter: DashboardFilter, parsed: Pick & { - byVariable: ReadonlyMap; + byVariable: ReadonlyMap; }, ): FilterSelection | undefined { const key = filterSelectionKey(filter); if (key.kind === 'variable') { const values = parsed.byVariable.get(key.name); - if (values) { + if (values?.type === 'variable') { return { - included: new Set(values), + included: new Set(values.values), excluded: new Set(), }; } diff --git a/packages/common-utils/src/filters.ts b/packages/common-utils/src/filters.ts index 0ad3dc1ae6..9fa6b71c35 100644 --- a/packages/common-utils/src/filters.ts +++ b/packages/common-utils/src/filters.ts @@ -950,16 +950,16 @@ export function getFilterExpression( return isQueryExpressionFilter(filter) ? filter.expression : undefined; } -/** What the filter broadcasts, or undefined when it cannot broadcast. */ +/** Where the filter broadcasts, or undefined when it cannot broadcast. */ export function getFilterBroadcastTarget( filter: DashboardFilter, -): { expression: string; appliesToSourceIds?: string[] } | undefined { - if (!isQueryExpressionFilter(filter) || !isFilterBroadcastEnabled(filter)) - return undefined; - return { - expression: filter.expression, - appliesToSourceIds: filter.appliesToSourceIds, - }; +): { appliesToSourceIds?: string[] } | undefined { + if (!isFilterBroadcastEnabled(filter)) return undefined; + if (isAdhocFilter(filter)) { + return { appliesToSourceIds: getAdhocFilterAppliesToSourceIds(filter) }; + } + if (!isQueryExpressionFilter(filter)) return undefined; + return { appliesToSourceIds: filter.appliesToSourceIds }; } /**