diff --git a/.changeset/adhoc-filter-ui.md b/.changeset/adhoc-filter-ui.md new file mode 100644 index 0000000000..0a464f980a --- /dev/null +++ b/.changeset/adhoc-filter-ui.md @@ -0,0 +1,6 @@ +--- +'@hyperdx/common-utils': patch +'@hyperdx/app': patch +--- + +feat: Add UI for configuring adhoc dashboard filters diff --git a/packages/app/.env.development b/packages/app/.env.development index af4a79ce0f..81ff15ce3b 100644 --- a/packages/app/.env.development +++ b/packages/app/.env.development @@ -13,4 +13,5 @@ NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:${HDX_DEV_OTEL_HTTP_PO # NEXT_PUBLIC_OTEL_EXPORTER_OTLP_METRICS_ENDPOINT= # NEXT_PUBLIC_OTEL_EXPORTER_OTLP_LOGS_ENDPOINT= NEXT_PUBLIC_ENABLE_PROMQL=true +NEXT_PUBLIC_ENABLE_ADHOC_FILTERS=true NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index ca3adee7f4..a0cd862caf 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -105,8 +105,8 @@ export default defineConfig({ { // Full UI: Alerts + Dashboards. Not local mode; Alerts enabled; command: USE_DEV - ? `SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true NEXT_PUBLIC_ENABLE_PROMQL=true next dev --webpack` - : `SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true NEXT_PUBLIC_ENABLE_PROMQL=true yarn build && SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true NEXT_PUBLIC_ENABLE_PROMQL=true yarn start`, + ? `SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true NEXT_PUBLIC_ENABLE_PROMQL=true NEXT_PUBLIC_ENABLE_ADHOC_FILTERS=true next dev --webpack` + : `SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true NEXT_PUBLIC_ENABLE_PROMQL=true NEXT_PUBLIC_ENABLE_ADHOC_FILTERS=true yarn build && SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true NEXT_PUBLIC_ENABLE_PROMQL=true NEXT_PUBLIC_ENABLE_ADHOC_FILTERS=true yarn start`, port: parseInt(APP_PORT, 10), reuseExistingServer: !process.env.CI, timeout: APP_SERVER_STARTUP_TIMEOUT_MS, diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 29bd584a28..b7e102324b 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -3524,7 +3524,7 @@ function DBDashboardPage({ setShowFiltersModal(false)} - filters={filters} + filters={dashboard?.filters ?? []} onSaveFilter={handleSaveFilter} onRemoveFilter={handleRemoveFilter} isLoading={isSavingDashboard || isFetchingDashboard} diff --git a/packages/app/src/__tests__/hdxDebug.test.ts b/packages/app/src/__tests__/hdxDebug.test.ts index 3c6760ec02..40bd113d08 100644 --- a/packages/app/src/__tests__/hdxDebug.test.ts +++ b/packages/app/src/__tests__/hdxDebug.test.ts @@ -4,6 +4,7 @@ const baseConfig = { APP_VERSION: '9.9.9', BASE_PATH: '', + IS_ADHOC_FILTERS_ENABLED: false, IS_ALERT_DETAILS_ENABLED: false, IS_CLICKHOUSE_BUILD: false, IS_LLM_COST_ENABLED: false, diff --git a/packages/app/src/components/DashboardFiltersModal/AdhocFilterEditForm.tsx b/packages/app/src/components/DashboardFiltersModal/AdhocFilterEditForm.tsx new file mode 100644 index 0000000000..83cffdf038 --- /dev/null +++ b/packages/app/src/components/DashboardFiltersModal/AdhocFilterEditForm.tsx @@ -0,0 +1,146 @@ +import { Controller, UseFormSetValue, useWatch } from 'react-hook-form'; +import { getAdhocFilterSourceKinds } from '@hyperdx/common-utils/dist/filters'; +import { + AdhocFilterSourceType, + DashboardFilter, + SourceKind, +} from '@hyperdx/common-utils/dist/types'; +import { Box, Divider, SegmentedControl } from '@mantine/core'; + +import { CheckBoxControlled } from '@/components/InputControlled'; +import { SourceMultiSelectControlled } from '@/components/SourceMultiSelect'; +import { IS_PROMQL_ENABLED } from '@/config'; +import { useSources } from '@/source'; + +import { CustomInputWrapper } from './CustomInputWrapper'; +import { FilterFormControl, FilterFormValues } from './filterFormState'; +import { VariableNameInput } from './VariableNameInput'; + +const SOURCE_TYPE_OPTIONS: { value: AdhocFilterSourceType; label: string }[] = [ + { value: 'sql', label: 'ClickHouse' }, + { value: 'promql', label: 'Prometheus' }, +]; + +interface AdhocFilterEditFormProps { + control: FilterFormControl; + setValue: UseFormSetValue; + /** Filters other than the one being edited, used to keep variable names unique. */ + otherFilters: DashboardFilter[]; +} + +/** Form describing an ad hoc dashboard filter */ +export const AdhocFilterEditForm = ({ + control, + setValue, + otherFilters, +}: AdhocFilterEditFormProps) => { + const { data: allSources } = useSources(); + const [sourceType, isBroadcastEnabled] = useWatch({ + control, + name: ['sourceType', 'isBroadcastEnabled'], + }); + const allowedSourceKinds = getAdhocFilterSourceKinds(sourceType); + // Kept visible for a saved PromQL filter so its source type can be changed + const showSourceType = + sourceType === 'promql' || + (IS_PROMQL_ENABLED && + !!allSources?.some(s => s.kind === SourceKind.Promql)); + + // The multi-selects hide sources of other kinds, which the form can still + // hold after switching from another filter type. + const validateSourceKinds = (ids: string[] | undefined) => { + const hasOtherKind = ids?.some(id => { + const source = allSources?.find(s => s.id === id); + return source && !allowedSourceKinds.includes(source.kind); + }); + return ( + !hasOtherKind || + (sourceType === 'promql' + ? 'Select only PromQL sources' + : 'Select only log, trace, or session sources') + ); + }; + + return ( + <> + {showSourceType && ( + + ( + { + onChange(next); + // Sources of the previous type can't be kept + setValue('sources', [], { shouldDirty: true }); + setValue('appliesToSourceIds', [], { shouldDirty: true }); + }} + data={SOURCE_TYPE_OPTIONS} + data-testid="adhoc-filter-source-type" + /> + )} + /> + + )} + + + !value?.length + ? 'Select at least one source' + : validateSourceKinds(value), + }} + /> + + + + + {isBroadcastEnabled && ( + + + + + + )} + + + + ); +}; diff --git a/packages/app/src/components/DashboardFiltersModal/DashboardFilterEditForm.tsx b/packages/app/src/components/DashboardFiltersModal/DashboardFilterEditForm.tsx index 6e2d194412..2a0dd96e23 100644 --- a/packages/app/src/components/DashboardFiltersModal/DashboardFilterEditForm.tsx +++ b/packages/app/src/components/DashboardFiltersModal/DashboardFilterEditForm.tsx @@ -26,10 +26,11 @@ import { ErrorBoundary } from '@/components/Error/ErrorBoundary'; import { CheckBoxControlled } from '@/components/InputControlled'; import SelectControlled from '@/components/SelectControlled'; import { SqlVariablesProvider } from '@/components/SQLEditor/variableCompletions'; -import { IS_PROMQL_ENABLED } from '@/config'; +import { IS_ADHOC_FILTERS_ENABLED, IS_PROMQL_ENABLED } from '@/config'; import { useConfirm } from '@/useConfirm'; import { useZIndex } from '@/zIndex'; +import { AdhocFilterEditForm } from './AdhocFilterEditForm'; import { MODAL_SIZE } from './constants'; import { CustomInputWrapper } from './CustomInputWrapper'; import { @@ -55,6 +56,14 @@ const FILTER_TYPE_OPTIONS = [ }, ] : []), + ...(IS_ADHOC_FILTERS_ENABLED + ? [ + { + value: DashboardFilterType.enum.ADHOC, + label: 'Ad hoc keys and values', + }, + ] + : []), ]; interface DashboardFilterEditFormProps { @@ -232,6 +241,12 @@ export const DashboardFilterEditForm = ({ control={control} otherFilters={otherFilters} /> + ) : formFilterType === 'ADHOC' ? ( + ) : formFilterType === 'PROMETHEUS_LABEL' ? ( - {showRequiredFilterOptions && ( + {showRequiredFilterOptions && formFilterType !== 'ADHOC' && ( void; } +function getSourceNames( + ids: string[], + sources?: { id: string; name: string }[], +) { + return ids + .map(id => sources?.find(s => s.id === id)?.name) + .filter((name): name is string => !!name) + .join(', '); +} + /** Where the dropdown's values come from: the queried source, or the authored list. */ function getValuesAttribute( filter: DashboardFilter, @@ -74,9 +88,12 @@ function getValuesAttribute( label: `${count} custom option${count === 1 ? '' : 's'}`, }; } - // Not shown in the dashboard UI yet case 'ADHOC': - return { icon: , label: '' }; + return { + icon: , + tooltip: 'Sources the condition keys and values come from', + label: getSourceNames(filter.sources, sources), + }; default: filter satisfies never; // exhaustive check return { icon: , label: '' }; @@ -87,6 +104,12 @@ 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; @@ -94,11 +117,7 @@ function getBroadcastTargetDisplay( return 'All sources'; } - const appliedSourceNames = broadcastTarget.appliesToSourceIds - .map(id => sources?.find(s => s.id === id)?.name) - .filter((name): name is string => !!name); - - return appliedSourceNames.join(', '); + return getSourceNames(broadcastTarget.appliesToSourceIds, sources); } export const DashboardFiltersList = ({ diff --git a/packages/app/src/components/DashboardFiltersModal/__tests__/filterFormState.test.ts b/packages/app/src/components/DashboardFiltersModal/__tests__/filterFormState.test.ts index 8b8d789d35..a19d565601 100644 --- a/packages/app/src/components/DashboardFiltersModal/__tests__/filterFormState.test.ts +++ b/packages/app/src/components/DashboardFiltersModal/__tests__/filterFormState.test.ts @@ -80,6 +80,33 @@ describe('toFormValues', () => { isVariableEnabled: true, }); }); + + it('seeds the ad hoc fields from a stored ad hoc filter', () => { + const values = toFormValues({ + id: 'a', + type: 'ADHOC', + name: 'Conditions', + sourceType: 'promql', + sources: ['prom'], + appliesToSourceIds: ['prom-2'], + isBroadcastEnabled: false, + isVariableEnabled: true, + variableName: 'conds', + }); + + expect(values).toMatchObject({ + type: 'ADHOC', + sourceType: 'promql', + sources: ['prom'], + appliesToSourceIds: ['prom-2'], + variableName: 'conds', + isBroadcastEnabled: false, + }); + }); + + it('defaults a new filter to SQL ad hoc sources', () => { + expect(toFormValues()).toMatchObject({ sourceType: 'sql', sources: [] }); + }); }); describe('toSavedFilter', () => { @@ -186,6 +213,59 @@ describe('toSavedFilter', () => { ).toThrow(); }); + it('keeps only the ad hoc fields on an ad hoc filter', () => { + const saved = toSavedFilter( + formValues({ + type: 'ADHOC', + name: 'Conditions', + sourceType: 'sql', + sources: ['logs', 'traces'], + appliesToSourceIds: ['logs'], + // Left behind by the other editors before the type was switched. + expression: 'Env', + source: 'logs', + options: ['prod'], + isRequired: true, + isSingleSelect: true, + }), + ); + + expect(saved).toEqual({ + id: 'a', + type: 'ADHOC', + name: 'Conditions', + sourceType: 'sql', + sources: ['logs', 'traces'], + appliesToSourceIds: ['logs'], + isBroadcastEnabled: true, + isVariableEnabled: true, + variableName: 'Conditions', + }); + }); + + it('drops the ad hoc broadcast scope when broadcast is off', () => { + const saved = toSavedFilter( + formValues({ + type: 'ADHOC', + name: 'Conditions', + sources: ['logs'], + appliesToSourceIds: ['logs'], + isBroadcastEnabled: false, + }), + ); + + expect(saved).toMatchObject({ + isBroadcastEnabled: false, + appliesToSourceIds: undefined, + }); + }); + + it('rejects an ad hoc filter without sources', () => { + expect(() => + toSavedFilter(formValues({ type: 'ADHOC', name: 'Conditions' })), + ).toThrow(); + }); + it('rejects values the schema cannot accept', () => { expect(() => toSavedFilter(formValues({ type: 'STATIC_LIST', options: [] })), diff --git a/packages/app/src/components/DashboardFiltersModal/filterFormState.ts b/packages/app/src/components/DashboardFiltersModal/filterFormState.ts index 2dac17acd0..538e317bc5 100644 --- a/packages/app/src/components/DashboardFiltersModal/filterFormState.ts +++ b/packages/app/src/components/DashboardFiltersModal/filterFormState.ts @@ -7,6 +7,7 @@ import { isFilterVariableEnabled, } from '@hyperdx/common-utils/dist/filters'; import { + AdhocDashboardFilter, DashboardFilter, DashboardFilterSchema, PromqlLabelDashboardFilter, @@ -51,7 +52,8 @@ export type FilterFormValues = { | 'minSelections' | 'isGlobalRequirement' | 'maxSelections' - >; + > & + Pick; export type FilterFormControl = Control; @@ -62,6 +64,7 @@ export const toFormValues = ( const queried = filter?.type === 'QUERY_EXPRESSION' ? filter : undefined; const staticList = filter?.type === 'STATIC_LIST' ? filter : undefined; const promqlLabel = filter?.type === 'PROMETHEUS_LABEL' ? filter : undefined; + const adhoc = filter?.type === 'ADHOC' ? filter : undefined; return { id: filter?.id ?? crypto.randomUUID(), @@ -80,7 +83,8 @@ export const toFormValues = ( sourceMetricType: queried?.sourceMetricType, where: queried?.where ?? '', whereLanguage: queried?.whereLanguage ?? getStoredLanguage() ?? 'sql', - appliesToSourceIds: queried?.appliesToSourceIds ?? [], + appliesToSourceIds: + queried?.appliesToSourceIds ?? adhoc?.appliesToSourceIds ?? [], // STATIC_LIST fields options: staticList?.options ?? [], @@ -88,6 +92,10 @@ export const toFormValues = ( // PROMETHEUS_LABEL fields label: promqlLabel?.label ?? '', match: promqlLabel?.match ?? '', + + // ADHOC fields + sourceType: adhoc?.sourceType ?? 'sql', + sources: adhoc?.sources ?? [], }; }; @@ -128,8 +136,27 @@ export const toSavedFilter = (values: FilterFormValues): DashboardFilter => { }); } - const trimmedWhere = values.where?.trim() ?? ''; const appliesTo = values.appliesToSourceIds?.filter(id => !!id?.length); + + // Selection limits don't apply to conditions, so they aren't stored. + if (values.type === 'ADHOC') { + const isBroadcastEnabled = isFilterBroadcastEnabled(values); + return DashboardFilterSchema.parse({ + id: values.id, + type: values.type, + name: values.name, + sourceType: values.sourceType, + sources: values.sources, + isBroadcastEnabled, + // Hidden, and so unvalidated, while broadcast is off + appliesToSourceIds: + isBroadcastEnabled && appliesTo?.length ? appliesTo : undefined, + isVariableEnabled: true, + variableName: getFilterVariableName(values), + }); + } + + const trimmedWhere = values.where?.trim() ?? ''; const isVariableEnabled = isFilterVariableEnabled(values); return DashboardFilterSchema.parse({ diff --git a/packages/app/src/config.ts b/packages/app/src/config.ts index c416d1e660..c0ce75adf6 100644 --- a/packages/app/src/config.ts +++ b/packages/app/src/config.ts @@ -82,6 +82,8 @@ export const IS_METRICS_ENABLED = true; export const IS_MTVIEWS_ENABLED = false; export const IS_SESSIONS_ENABLED = true; export const IS_PROMQL_ENABLED = env('NEXT_PUBLIC_ENABLE_PROMQL') === 'true'; +export const IS_ADHOC_FILTERS_ENABLED = + env('NEXT_PUBLIC_ENABLE_ADHOC_FILTERS') === 'true'; // Suffix for the tab title/sidebar pill. Shown in full in the page title; // only truncated visually (via CSS ellipsis) in the sidebar, where space is // constrained. Internal whitespace is collapsed so it displays consistently diff --git a/packages/app/src/hdxDebug.ts b/packages/app/src/hdxDebug.ts index 1f78812761..c5d5981e7d 100644 --- a/packages/app/src/hdxDebug.ts +++ b/packages/app/src/hdxDebug.ts @@ -5,6 +5,7 @@ import { APP_VERSION, BASE_PATH, INSTANCE_LABEL, + IS_ADHOC_FILTERS_ENABLED, IS_ALERT_DETAILS_ENABLED, IS_CLICKHOUSE_BUILD, IS_DEV, @@ -85,6 +86,7 @@ const staticFeatures: Record = { promql: IS_PROMQL_ENABLED, llmCost: IS_LLM_COST_ENABLED, alertDetails: IS_ALERT_DETAILS_ENABLED, + adhocFilters: IS_ADHOC_FILTERS_ENABLED, instanceLabel: Boolean(INSTANCE_LABEL), }; diff --git a/packages/app/tests/e2e/features/dashboard-adhoc-filters.spec.ts b/packages/app/tests/e2e/features/dashboard-adhoc-filters.spec.ts new file mode 100644 index 0000000000..e64ed257cc --- /dev/null +++ b/packages/app/tests/e2e/features/dashboard-adhoc-filters.spec.ts @@ -0,0 +1,310 @@ +/** + * 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. + */ +import { DashboardPage } from '../page-objects/DashboardPage'; +import { expect, test } from '../utils/base-test'; +import { + DEFAULT_LOGS_SOURCE_NAME, + DEFAULT_METRICS_SOURCE_NAME, + DEFAULT_TRACES_SOURCE_NAME, + PROMQL_SOURCE_NAME, +} from '../utils/constants'; + +test.describe( + 'Ad hoc dashboard filters', + { tag: ['@dashboard', '@full-stack'] }, + () => { + let dashboardPage: DashboardPage; + + test.beforeEach(async ({ page }) => { + dashboardPage = new DashboardPage(page); + await dashboardPage.goto(); + }); + + test('authors a SQL filter over several sources', async ({ page }) => { + await test.step('Open the add-filter form and pick the ad hoc type', async () => { + await dashboardPage.createNewDashboard(); + await dashboardPage.openEditFiltersModal(); + await dashboardPage.openAddFilterForm(); + await dashboardPage.getFilterNameInput().fill('Conditions'); + await dashboardPage.selectFilterType('Ad hoc keys and values'); + await expect(dashboardPage.getFilterNameInput()).toHaveValue( + 'Conditions', + ); + }); + + await test.step('Only the fields an ad hoc filter has are rendered', async () => { + const form = dashboardPage.getFilterForm(); + await expect( + dashboardPage.getAdhocFilterSourceTypeControl(), + ).toBeVisible(); + await expect(dashboardPage.getAdhocFilterSourcesInput()).toBeVisible(); + await expect(dashboardPage.broadcastFilterCheckbox).toBeChecked(); + await expect(dashboardPage.appliesToSourceSelector).toBeVisible(); + // Always a variable, so the name is asked for rather than unlocked by + // a checkbox. + await expect(dashboardPage.variableNameInput).toBeVisible(); + + await expect(form.getByTestId('source-selector')).toHaveCount(0); + await expect(form.locator('div.cm-editor')).toHaveCount(0); + await expect( + form.getByTestId('filter-variable-enabled-checkbox'), + ).toHaveCount(0); + await expect(dashboardPage.requiredFilterCheckbox).toHaveCount(0); + await expect(dashboardPage.singleSelectCheckbox).toHaveCount(0); + }); + + await test.step('Saving without a source is rejected', async () => { + await page.getByTestId('save-filter-button').click(); + await expect( + dashboardPage.getFilterForm().getByText('Select at least one source'), + ).toBeVisible(); + }); + + await test.step('Only SQL sources without a metric type are offered', async () => { + await dashboardPage.getAdhocFilterSourcesInput().click(); + await expect( + dashboardPage.getFilterOption(DEFAULT_LOGS_SOURCE_NAME), + ).toBeVisible(); + await expect( + dashboardPage.getFilterOption(DEFAULT_TRACES_SOURCE_NAME), + ).toBeVisible(); + await expect( + dashboardPage.getFilterOption(DEFAULT_METRICS_SOURCE_NAME), + ).toHaveCount(0); + await expect( + dashboardPage.getFilterOption(PROMQL_SOURCE_NAME), + ).toHaveCount(0); + await page.keyboard.press('Escape'); + }); + + await test.step('The saved filter is summarized by its sources', async () => { + await dashboardPage.selectSourcesInMultiSelect( + dashboardPage.getAdhocFilterSourcesInput(), + [DEFAULT_LOGS_SOURCE_NAME, DEFAULT_TRACES_SOURCE_NAME], + ); + await dashboardPage.variableNameInput.fill('conds'); + await page.getByTestId('save-filter-button').click(); + + const item = dashboardPage.getFilterItemByName('Conditions'); + await expect(item).toBeVisible(); + await expect(item).toContainText( + `${DEFAULT_LOGS_SOURCE_NAME}, ${DEFAULT_TRACES_SOURCE_NAME}`, + ); + await expect(item).toContainText('($conds)'); + await expect( + dashboardPage.getFilterBroadcastTarget('Conditions'), + ).toHaveText( + `${DEFAULT_LOGS_SOURCE_NAME}, ${DEFAULT_TRACES_SOURCE_NAME}`, + ); + }); + + await test.step('The filter survives a reload', async () => { + await dashboardPage.closeFiltersModal(); + await page.reload(); + await dashboardPage.waitForLoaded(); + await dashboardPage.openEditFiltersModal(); + await expect( + dashboardPage.getFilterItemByName('Conditions'), + ).toContainText('($conds)'); + }); + }); + + test('edits the broadcast scope and variable name of a saved filter', async ({ + page, + }) => { + await test.step('Create a SQL ad hoc filter', async () => { + await dashboardPage.createNewDashboard(); + await dashboardPage.openEditFiltersModal(); + await dashboardPage.addAdhocFilterToDashboard( + 'Conditions', + [DEFAULT_LOGS_SOURCE_NAME, DEFAULT_TRACES_SOURCE_NAME], + { variableName: 'conds' }, + ); + }); + + await test.step('The edit form loads the saved values', async () => { + await dashboardPage.openEditFilterForm('Conditions'); + const form = dashboardPage.getFilterForm(); + await expect(dashboardPage.getFilterTypePicker()).toHaveValue( + 'Ad hoc keys and values', + ); + await expect( + form.getByText(DEFAULT_LOGS_SOURCE_NAME, { exact: true }), + ).toBeVisible(); + await expect( + form.getByText(DEFAULT_TRACES_SOURCE_NAME, { exact: true }), + ).toBeVisible(); + await expect(dashboardPage.variableNameInput).toHaveValue('conds'); + }); + + await test.step('Narrow the broadcast to one source', async () => { + await dashboardPage.selectSourcesInMultiSelect( + dashboardPage.appliesToSourceSelector, + [DEFAULT_TRACES_SOURCE_NAME], + ); + await dashboardPage.variableNameInput.fill('where_clause'); + await page.getByTestId('save-filter-button').click(); + + await expect( + dashboardPage.getFilterItemByName('Conditions'), + ).toContainText('($where_clause)'); + await expect( + dashboardPage.getFilterBroadcastTarget('Conditions'), + ).toHaveText(DEFAULT_TRACES_SOURCE_NAME); + }); + + await test.step('Turning broadcast off drops the broadcast attribute', async () => { + await dashboardPage.openEditFilterForm('Conditions'); + await dashboardPage.broadcastFilterCheckbox.uncheck(); + await expect(dashboardPage.appliesToSourceSelector).toHaveCount(0); + await page.getByTestId('save-filter-button').click(); + + await expect( + dashboardPage.getFilterItemByName('Conditions'), + ).toBeVisible(); + await expect( + dashboardPage.getFilterBroadcastTarget('Conditions'), + ).toHaveCount(0); + }); + + await test.step('The edits survive a reload', async () => { + await page.reload(); + await dashboardPage.waitForLoaded(); + await dashboardPage.openEditFiltersModal(); + await dashboardPage.openEditFilterForm('Conditions'); + await expect(dashboardPage.broadcastFilterCheckbox).not.toBeChecked(); + await expect(dashboardPage.variableNameInput).toHaveValue( + 'where_clause', + ); + }); + }); + + test('authors a PromQL filter', async ({ page }) => { + await test.step('Switching source type clears the picked sources', async () => { + await dashboardPage.createNewDashboard(); + await dashboardPage.openEditFiltersModal(); + await dashboardPage.openAddFilterForm(); + await dashboardPage.getFilterNameInput().fill('Labels'); + await dashboardPage.selectFilterType('Ad hoc keys and values'); + await dashboardPage.selectSourcesInMultiSelect( + dashboardPage.getAdhocFilterSourcesInput(), + [DEFAULT_LOGS_SOURCE_NAME], + ); + const form = dashboardPage.getFilterForm(); + await expect( + form.getByText(DEFAULT_LOGS_SOURCE_NAME, { exact: true }), + ).toBeVisible(); + + await dashboardPage.selectAdhocFilterSourceType('Prometheus'); + await expect( + form.getByText(DEFAULT_LOGS_SOURCE_NAME, { exact: true }), + ).toHaveCount(0); + }); + + await test.step('Only PromQL sources are offered', async () => { + await dashboardPage.getAdhocFilterSourcesInput().click(); + await expect( + dashboardPage.getFilterOption(PROMQL_SOURCE_NAME), + ).toBeVisible(); + await expect( + dashboardPage.getFilterOption(DEFAULT_LOGS_SOURCE_NAME), + ).toHaveCount(0); + await dashboardPage.getFilterOption(PROMQL_SOURCE_NAME).click(); + await page.keyboard.press('Escape'); + }); + + await test.step('The saved filter keeps its source type', async () => { + await page.getByTestId('save-filter-button').click(); + await expect(dashboardPage.getFilterItemByName('Labels')).toContainText( + PROMQL_SOURCE_NAME, + ); + + await dashboardPage.openEditFilterForm('Labels'); + await expect( + dashboardPage + .getAdhocFilterSourceTypeControl() + .getByRole('radio', { name: 'Prometheus' }), + ).toBeChecked(); + }); + }); + + test('rejects broadcast sources carried over from another filter type', async ({ + page, + }) => { + await dashboardPage.createNewDashboard(); + await dashboardPage.openEditFiltersModal(); + await dashboardPage.openAddFilterForm(); + await dashboardPage.getFilterNameInput().fill('Conditions'); + await dashboardPage.selectSourcesInMultiSelect( + dashboardPage.appliesToSourceSelector, + [DEFAULT_METRICS_SOURCE_NAME], + ); + + await dashboardPage.selectFilterType('Ad hoc keys and values'); + await dashboardPage.selectSourcesInMultiSelect( + dashboardPage.getAdhocFilterSourcesInput(), + [DEFAULT_LOGS_SOURCE_NAME], + ); + await page.getByTestId('save-filter-button').click(); + + await expect( + dashboardPage + .getFilterForm() + .getByText('Select only log, trace, or session sources'), + ).toBeVisible(); + }); + + 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(); + await dashboardPage.openEditFiltersModal(); + await dashboardPage.addAdhocFilterToDashboard( + 'Conditions', + [DEFAULT_LOGS_SOURCE_NAME], + { variableName: 'conds' }, + ); + await dashboardPage.addStaticListFilterToDashboard('Environment', [ + 'prod', + ]); + }); + + await test.step('Other filters cannot reuse its variable name', async () => { + await dashboardPage.openEditFilterForm('Environment'); + await dashboardPage.variableNameInput.fill('conds'); + await page.getByTestId('save-filter-button').click(); + await expect( + dashboardPage + .getFilterForm() + .getByText( + 'This variable name is used by another filter on this dashboard (Conditions)', + ), + ).toBeVisible(); + await page.getByRole('button', { name: 'Cancel' }).click(); + }); + + await test.step('Only the other filter is rendered on the dashboard', async () => { + await dashboardPage.closeFiltersModal(); + await expect( + dashboardPage.getFilterSelectByName('Environment'), + ).toBeVisible(); + await expect( + dashboardPage.getFilterSelectByName('Conditions'), + ).toHaveCount(0); + }); + + await test.step('Deleting it leaves the other filter in place', async () => { + await dashboardPage.openEditFiltersModal(); + await dashboardPage.deleteFilterFromDashboard('Conditions'); + await expect( + dashboardPage.getFilterItemByName('Conditions'), + ).toHaveCount(0); + await expect( + dashboardPage.getFilterItemByName('Environment'), + ).toBeVisible(); + }); + }); + }, +); diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts index b72a03b77c..974646c7d1 100644 --- a/packages/app/tests/e2e/page-objects/DashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts @@ -965,14 +965,10 @@ export class DashboardPage { await this.setFilterRequirement(variableOptions); if (appliesToSourceNames && appliesToSourceNames.length > 0) { - for (const appliesName of appliesToSourceNames) { - await this.appliesToSourceSelector.click(); - await this.page - .getByRole('option', { name: appliesName, exact: true }) - .click(); - } - // Close the dropdown so the save button is clickable. - await this.page.keyboard.press('Escape'); + await this.selectSourcesInMultiSelect( + this.appliesToSourceSelector, + appliesToSourceNames, + ); } const saveFilterButton = this.page.getByTestId('save-filter-button'); @@ -1150,6 +1146,11 @@ export class DashboardPage { return this.page.getByTestId(`dashboard-filter-item-${name}`); } + /** The filters list's summary of which sources a filter broadcasts to. */ + getFilterBroadcastTarget(name: string) { + return this.page.getByTestId(`dashboard-filter-applies-to-${name}`); + } + getFilterSelectByName(name: string) { return this.page.getByTestId(`dashboard-filter-select-${name}`); } @@ -1364,7 +1365,11 @@ export class DashboardPage { /** Switch the add-filter form between the available value types. */ async selectFilterType( - label: 'Queried values' | 'Static values' | 'PromQL label values', + label: + | 'Queried values' + | 'Static values' + | 'PromQL label values' + | 'Ad hoc keys and values', ) { await this.getFilterTypePicker().click(); await this.getFilterOption(label).click(); @@ -1529,6 +1534,60 @@ export class DashboardPage { }); } + /** The ad hoc filter form's ClickHouse / Prometheus source type control. */ + getAdhocFilterSourceTypeControl(): Locator { + return this.getFilterForm().getByTestId('adhoc-filter-source-type'); + } + + /** The ad hoc filter form's data sources multi-select. */ + getAdhocFilterSourcesInput(): Locator { + return this.getFilterForm().getByTestId('adhoc-filter-sources'); + } + + /** Switch the ad hoc filter form between ClickHouse and Prometheus sources. */ + async selectAdhocFilterSourceType(label: 'ClickHouse' | 'Prometheus') { + await this.getAdhocFilterSourceTypeControl() + .getByText(label, { exact: true }) + .click(); + } + + /** Add `sourceNames` to a source multi-select, then close its dropdown. */ + async selectSourcesInMultiSelect(input: Locator, sourceNames: string[]) { + for (const sourceName of sourceNames) { + await input.click(); + await this.getFilterOption(sourceName).click(); + } + // Close the dropdown so the save button is clickable. + await this.page.keyboard.press('Escape'); + } + + /** + * Add an ad hoc filter over `sourceNames` and save it. Assumes the Edit + * Filters modal is already open; leaves it open on the filters list, having + * waited for the new filter to land there. + */ + async addAdhocFilterToDashboard( + name: string, + sourceNames: string[], + options?: { variableName?: string }, + ) { + await this.addFiltersButton.click(); + await this.selectFilterType('Ad hoc keys and values'); + await this.getFilterNameInput().fill(name); + await this.selectSourcesInMultiSelect( + this.getAdhocFilterSourcesInput(), + sourceNames, + ); + if (options?.variableName !== undefined) { + await this.variableNameInput.fill(options.variableName); + } + await this.page.getByTestId('save-filter-button').click(); + await this.getFilterItemByName(name).waitFor({ + state: 'visible', + timeout: 10000, + }); + } + /** * 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/common-utils/src/__tests__/filters.test.ts b/packages/common-utils/src/__tests__/filters.test.ts index 3a68bc1a97..aff7b0326d 100644 --- a/packages/common-utils/src/__tests__/filters.test.ts +++ b/packages/common-utils/src/__tests__/filters.test.ts @@ -4,6 +4,7 @@ import { FilterState, filterStateToPredicate, filtersToQuery, + getAdhocFilterAppliesToSourceIds, getDashboardVariableDeclarations, getDashboardVariableFilters, getFilterBroadcastTarget, @@ -1718,6 +1719,29 @@ describe('filters', () => { }); }); + describe('getAdhocFilterAppliesToSourceIds', () => { + it('falls back to the filter sources when no scope is set', () => { + expect(getAdhocFilterAppliesToSourceIds({ sources: ['logs'] })).toEqual([ + 'logs', + ]); + expect( + getAdhocFilterAppliesToSourceIds({ + sources: ['logs'], + appliesToSourceIds: [], + }), + ).toEqual(['logs']); + }); + + it('returns the scoped sources when set', () => { + expect( + getAdhocFilterAppliesToSourceIds({ + sources: ['logs', 'traces'], + appliesToSourceIds: ['traces'], + }), + ).toEqual(['traces']); + }); + }); + describe('doesFilterApplyToSource', () => { const filter = ( overrides: Partial = {}, diff --git a/packages/common-utils/src/filters.ts b/packages/common-utils/src/filters.ts index 33cdf604b6..0ad3dc1ae6 100644 --- a/packages/common-utils/src/filters.ts +++ b/packages/common-utils/src/filters.ts @@ -3,6 +3,7 @@ import * as SQLParser from 'node-sql-parser'; import { escapeSqlString, replaceJsonExpressions } from '@/core/utils'; import { parse } from '@/queryParser'; import { + AdhocDashboardFilter, AdhocFilterSourceType, AdhocFilterValue, ChartVariable, @@ -923,6 +924,18 @@ export function isAdhocFilter( return filter.type === 'ADHOC'; } +/** + * The sources an ADHOC filter broadcasts its conditions to: its + * `appliesToSourceIds`, or its own `sources` when that is unset or empty. + */ +export function getAdhocFilterAppliesToSourceIds( + filter: Pick, +): string[] { + return filter.appliesToSourceIds?.length + ? filter.appliesToSourceIds + : filter.sources; +} + /** Type guard for the saved conditions of an ADHOC filter. */ export function isAdhocFilterValue( value: DashboardFilterValue,