From ff10f6da67ad1e75531b12a7ae9400404e051bba Mon Sep 17 00:00:00 2001 From: Sam Jewell <2903904+samjewell@users.noreply.github.com> Date: Tue, 5 Aug 2025 10:27:09 +0100 Subject: [PATCH] [--Dashboard-- data source] Implement `getFiltersApplicability()` (#108517) **What is this feature?** Implement `getFiltersApplicability()` on the `--Dashboard--` data source Also refactor some existing code to use this new approach to naming This follows some previous work to implement AdHoc filtering on the `--Dashboard--` data source in PR #108011 **Why do we need this feature?** See https://github.com/grafana/grafana/pull/107775 and https://github.com/grafana/grafana/pull/106756 > We want to see, when carrying filters from one dashboard to another, which of them are still applicable and which are not Additionally I think @mdvictor is hoping to see more consistency in our approach across the codebase. But maybe he can confirm this. **Who is this feature for?** Everyone --- .../datasource/dashboard/datasource.test.ts | 95 +++++++++++++++++++ .../datasource/dashboard/datasource.ts | 90 +++++++++++++----- 2 files changed, 163 insertions(+), 22 deletions(-) diff --git a/public/app/plugins/datasource/dashboard/datasource.test.ts b/public/app/plugins/datasource/dashboard/datasource.test.ts index 3bb2fbc6b3c..5f522be6d2b 100644 --- a/public/app/plugins/datasource/dashboard/datasource.test.ts +++ b/public/app/plugins/datasource/dashboard/datasource.test.ts @@ -569,6 +569,101 @@ describe('DashboardDatasource', () => { expect(result.length).toBe(2); }); }); + + describe('getFiltersApplicability', () => { + const originalToggleValue = config.featureToggles.dashboardDsAdHocFiltering; + const ds = new DashboardDatasource({} as DataSourceInstanceSettings); + + beforeEach(() => { + config.featureToggles.dashboardDsAdHocFiltering = true; + }); + + afterEach(() => { + config.featureToggles.dashboardDsAdHocFiltering = originalToggleValue; + }); + + it('should return empty array when feature toggle is disabled', async () => { + config.featureToggles.dashboardDsAdHocFiltering = false; + + const result = await ds.getFiltersApplicability({ + filters: [{ key: 'name', operator: '=', value: 'test' }], + }); + + expect(result).toEqual([]); + }); + + it('should mark supported operators as applicable', async () => { + const result = await ds.getFiltersApplicability({ + filters: [ + { key: 'name', operator: '=', value: 'John' }, + { key: 'age', operator: '!=', value: '25' }, + ], + }); + + expect(result).toEqual([ + { key: 'name', applicable: true }, + { key: 'age', applicable: true }, + ]); + }); + + it('should mark unsupported operators as not applicable with reason', async () => { + const result = await ds.getFiltersApplicability({ + filters: [ + { key: 'name', operator: '>', value: 'John' }, + { key: 'age', operator: '<', value: '25' }, + { key: 'score', operator: '=~', value: 'pattern' }, + ], + }); + + expect(result).toEqual([ + { + key: 'name', + applicable: false, + reason: "Operator '>' is not supported. Only '=' and '!=' operators are supported.", + }, + { + key: 'age', + applicable: false, + reason: "Operator '<' is not supported. Only '=' and '!=' operators are supported.", + }, + { + key: 'score', + applicable: false, + reason: "Operator '=~' is not supported. Only '=' and '!=' operators are supported.", + }, + ]); + }); + + it('should handle mixed applicable and non-applicable filters', async () => { + const result = await ds.getFiltersApplicability({ + filters: [ + { key: 'name', operator: '=', value: 'John' }, + { key: 'age', operator: '>', value: '25' }, + { key: 'status', operator: '!=', value: 'active' }, + ], + }); + + expect(result).toEqual([ + { key: 'name', applicable: true }, + { + key: 'age', + applicable: false, + reason: "Operator '>' is not supported. Only '=' and '!=' operators are supported.", + }, + { key: 'status', applicable: true }, + ]); + }); + + it('should handle empty filters array', async () => { + const result = await ds.getFiltersApplicability({ filters: [] }); + expect(result).toEqual([]); + }); + + it('should handle missing options', async () => { + const result = await ds.getFiltersApplicability(); + expect(result).toEqual([]); + }); + }); }); }); diff --git a/public/app/plugins/datasource/dashboard/datasource.ts b/public/app/plugins/datasource/dashboard/datasource.ts index e98e140eb0a..524b044fed0 100644 --- a/public/app/plugins/datasource/dashboard/datasource.ts +++ b/public/app/plugins/datasource/dashboard/datasource.ts @@ -17,6 +17,8 @@ import { MetricFindValue, getValueMatcher, ValueMatcherID, + FiltersApplicability, + DataSourceGetTagKeysOptions, } from '@grafana/data'; import { config } from '@grafana/runtime'; import { SceneDataProvider, SceneDataTransformer, SceneObject } from '@grafana/scenes'; @@ -157,30 +159,18 @@ export class DashboardDatasource extends DataSourceApi { return frame; } - // Pre-compute field indices and value matchers for better performance - const filterFieldIndices = filters - .map((filter) => { - const fieldIndex = frame.fields.findIndex((f) => f.name === filter.key); - return { filter, fieldIndex, matcher: this.createValueMatcher(filter, fieldIndex, frame) }; - }) - .filter(({ filter, fieldIndex, matcher }) => { - // If field is not present: - // - Keep filters with '=' operator (will always be false - reject rows) - // - Remove filters with '!=' operator (will always be true - no effect) - if (fieldIndex === -1) { - return filter.operator === '='; - } - // Only keep filters with valid matchers - return matcher !== null; - }); + // Filter out non-applicable filters for this specific DataFrame + const applicableFilters = this.getApplicableFiltersForFrame(frame, filters); - // If no filters remain after optimization, return original frame - if (filterFieldIndices.length === 0) { + // If no filters remain after filtering, return original frame + if (applicableFilters.length === 0) { return frame; } - // Short-circuit: if any filter has '=' operator with missing field, reject all rows - const hasImpossibleFilter = filterFieldIndices.some(({ fieldIndex }) => fieldIndex === -1); + // Check for impossible filters (missing field with '=' operator) + const hasImpossibleFilter = applicableFilters.some( + ({ fieldIndex, filter }) => fieldIndex === -1 && filter.operator === '=' + ); if (hasImpossibleFilter) { return this.reconstructDataFrame(frame); } @@ -189,7 +179,7 @@ export class DashboardDatasource extends DataSourceApi { // Check each row to see if it matches all filters (AND logic) for (let rowIndex = 0; rowIndex < frame.length; rowIndex++) { - const rowMatches = filterFieldIndices.every(({ matcher, fieldIndex }) => { + const rowMatches = applicableFilters.every(({ matcher, fieldIndex }) => { const field = frame.fields[fieldIndex]; // Use Grafana's value matcher system @@ -210,7 +200,31 @@ export class DashboardDatasource extends DataSourceApi { } /** - * Create a value matcher from an AdHoc filter + * Get applicable filters for a specific DataFrame, considering field existence and type compatibility. + */ + private getApplicableFiltersForFrame( + frame: DataFrame, + filters: AdHocVariableFilter[] + ): Array<{ filter: AdHocVariableFilter; fieldIndex: number; matcher: ReturnType | null }> { + return filters + .map((filter) => { + const fieldIndex = frame.fields.findIndex((f) => f.name === filter.key); + return { filter, fieldIndex, matcher: this.createValueMatcher(filter, fieldIndex, frame) }; + }) + .filter(({ filter, fieldIndex, matcher }) => { + // If field is not present: + // - Keep filters with '=' operator (will always be false - reject rows) + // - Remove filters with '!=' operator (will always be true - no effect) + if (fieldIndex === -1) { + return filter.operator === '='; + } + // Only keep filters with valid matchers + return matcher !== null; + }); + } + + /** + * Create a value matcher from an AdHoc filter. */ private createValueMatcher(filter: AdHocVariableFilter, fieldIndex: number, frame: DataFrame) { // Return null for missing fields - they are handled separately @@ -329,6 +343,38 @@ export class DashboardDatasource extends DataSourceApi { return Promise.resolve({ message: '', status: '' }); } + /** + * Check which AdHoc filters are applicable based on operator and field type support + */ + async getFiltersApplicability( + options?: DataSourceGetTagKeysOptions + ): Promise { + if (!config.featureToggles.dashboardDsAdHocFiltering) { + return []; + } + + const filters = options?.filters || []; + + return filters.map((filter): FiltersApplicability => { + // Check operator support + if (filter.operator !== '=' && filter.operator !== '!=') { + return { + key: filter.key, + applicable: false, + reason: `Operator '${filter.operator}' is not supported. Only '=' and '!=' operators are supported.`, + }; + } + + // For dashboard datasource, we can't determine field existence/type + // without the actual DataFrame context, so we assume applicable here + // and let the actual filtering logic handle field-specific checks + return { + key: filter.key, + applicable: true, + }; + }); + } + getTagKeys(): Promise { // Stub implementation to indicate AdHoc filter support // Full implementation will be added in future PRs