diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts
index db6b0327350..534d8064bbc 100644
--- a/packages/grafana-data/src/types/featureToggles.gen.ts
+++ b/packages/grafana-data/src/types/featureToggles.gen.ts
@@ -1054,4 +1054,8 @@ export interface FeatureToggles {
* Enable dual reader for unified storage search
*/
unifiedStorageSearchDualReaderEnabled?: boolean;
+ /**
+ * Enables adhoc filtering support for the dashboard datasource
+ */
+ dashboardDsAdHocFiltering?: boolean;
}
diff --git a/pkg/services/featuremgmt/codeowners.go b/pkg/services/featuremgmt/codeowners.go
index e4647a59a6f..75e868fd57b 100644
--- a/pkg/services/featuremgmt/codeowners.go
+++ b/pkg/services/featuremgmt/codeowners.go
@@ -8,6 +8,7 @@ const (
grafanaAppPlatformSquad codeowner = "@grafana/grafana-app-platform-squad"
grafanaDashboardsSquad codeowner = "@grafana/dashboards-squad"
grafanaDatavizSquad codeowner = "@grafana/dataviz-squad"
+ grafanaDataProSquad codeowner = "@grafana/datapro"
grafanaFrontendPlatformSquad codeowner = "@grafana/grafana-frontend-platform"
grafanaFrontendSearchNavOrganise codeowner = "@grafana/grafana-search-navigate-organise"
grafanaBackendServicesSquad codeowner = "@grafana/grafana-backend-services-squad"
diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go
index b595a5bf480..35b8b06872d 100644
--- a/pkg/services/featuremgmt/registry.go
+++ b/pkg/services/featuremgmt/registry.go
@@ -1821,6 +1821,13 @@ var (
HideFromAdminPage: true,
HideFromDocs: true,
},
+ {
+ Name: "dashboardDsAdHocFiltering",
+ Description: "Enables adhoc filtering support for the dashboard datasource",
+ Stage: FeatureStageExperimental,
+ Owner: grafanaDataProSquad,
+ FrontendOnly: true,
+ },
}
)
diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv
index b3731aa5c3f..7c8dbf691f4 100644
--- a/pkg/services/featuremgmt/toggles_gen.csv
+++ b/pkg/services/featuremgmt/toggles_gen.csv
@@ -235,3 +235,4 @@ otelLogsFormatting,experimental,@grafana/observability-logs,false,false,true
alertingNotificationHistory,experimental,@grafana/alerting-squad,false,false,false
pluginAssetProvider,experimental,@grafana/plugins-platform-backend,false,true,false
unifiedStorageSearchDualReaderEnabled,experimental,@grafana/search-and-storage,false,false,false
+dashboardDsAdHocFiltering,experimental,@grafana/datapro,false,false,true
diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go
index a6a36e494e9..c94945edc9a 100644
--- a/pkg/services/featuremgmt/toggles_gen.go
+++ b/pkg/services/featuremgmt/toggles_gen.go
@@ -950,4 +950,8 @@ const (
// FlagUnifiedStorageSearchDualReaderEnabled
// Enable dual reader for unified storage search
FlagUnifiedStorageSearchDualReaderEnabled = "unifiedStorageSearchDualReaderEnabled"
+
+ // FlagDashboardDsAdHocFiltering
+ // Enables adhoc filtering support for the dashboard datasource
+ FlagDashboardDsAdHocFiltering = "dashboardDsAdHocFiltering"
)
diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json
index 07d86946b21..f66d89f5c28 100644
--- a/pkg/services/featuremgmt/toggles_gen.json
+++ b/pkg/services/featuremgmt/toggles_gen.json
@@ -796,6 +796,19 @@
"codeowner": "@grafana/grafana-app-platform-squad"
}
},
+ {
+ "metadata": {
+ "name": "dashboardDsAdHocFiltering",
+ "resourceVersion": "1752487974435",
+ "creationTimestamp": "2025-07-14T10:12:54Z"
+ },
+ "spec": {
+ "description": "Enables adhoc filtering support for the dashboard datasource",
+ "stage": "experimental",
+ "codeowner": "@grafana/datapro",
+ "frontend": true
+ }
+ },
{
"metadata": {
"name": "dashboardNewLayouts",
diff --git a/public/app/features/dashboard-scene/settings/variables/components/AdHocVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/AdHocVariableForm.tsx
index fcdf7881fce..29183c79531 100644
--- a/public/app/features/dashboard-scene/settings/variables/components/AdHocVariableForm.tsx
+++ b/public/app/features/dashboard-scene/settings/variables/components/AdHocVariableForm.tsx
@@ -4,6 +4,7 @@ import { DataSourceInstanceSettings, MetricFindValue, readCSV } from '@grafana/d
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { EditorField } from '@grafana/plugin-ui';
+import { config } from '@grafana/runtime';
import { DataSourceRef } from '@grafana/schema';
import { Alert, CodeEditor, Field, Switch, Box } from '@grafana/ui';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
@@ -61,7 +62,14 @@ export function AdHocVariableForm({
htmlFor="data-source-picker"
tooltip={infoText}
>
-
+
diff --git a/public/app/plugins/datasource/dashboard/datasource.test.ts b/public/app/plugins/datasource/dashboard/datasource.test.ts
index 26413c094df..3bb2fbc6b3c 100644
--- a/public/app/plugins/datasource/dashboard/datasource.test.ts
+++ b/public/app/plugins/datasource/dashboard/datasource.test.ts
@@ -7,15 +7,19 @@ import {
getDefaultTimeRange,
LoadingState,
standardTransformersRegistry,
+ FieldType,
+ DataFrame,
+ AdHocVariableFilter,
} from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test';
-import { setPluginImportUtils } from '@grafana/runtime';
+import { setPluginImportUtils, config } from '@grafana/runtime';
import {
SafeSerializableSceneObject,
SceneDataNode,
SceneDataTransformer,
SceneFlexItem,
SceneFlexLayout,
+ SceneObject,
VizPanel,
} from '@grafana/scenes';
import { getVizPanelKeyForPanelId } from 'app/features/dashboard-scene/utils/utils';
@@ -131,6 +135,441 @@ describe('DashboardDatasource', () => {
// on further emissions the result should be the unmodified original dataframe
expect(rsp!.data[0].fields[0].state).toEqual({});
});
+
+ describe('AdHoc Filtering', () => {
+ // Shared test utilities
+ interface TestField {
+ name: string;
+ type: FieldType;
+ values: unknown[];
+ }
+
+ function createTestFrame(fields: TestField[]) {
+ return {
+ name: 'TestData',
+ fields: fields.map((field) => ({
+ name: field.name,
+ type: field.type,
+ values: field.values,
+ config: {},
+ state: {},
+ })),
+ length: fields[0]?.values.length || 0,
+ refId: 'A',
+ };
+ }
+
+ function createQueryRequest(filters: AdHocVariableFilter[], scene: SceneObject) {
+ return {
+ timezone: 'utc',
+ targets: [{ refId: 'A', panelId: 1 }],
+ requestId: '',
+ interval: '',
+ intervalMs: 0,
+ range: getDefaultTimeRange(),
+ scopedVars: {
+ __sceneObject: new SafeSerializableSceneObject(scene),
+ },
+ app: '',
+ startTime: 0,
+ filters: filters,
+ };
+ }
+
+ // Test AdHoc filtering via the Public API first, to ensure Integration
+ describe('Integration (Public API)', () => {
+ const originalToggleValue = config.featureToggles.dashboardDsAdHocFiltering;
+
+ beforeEach(() => {
+ config.featureToggles.dashboardDsAdHocFiltering = true;
+ });
+
+ afterEach(() => {
+ config.featureToggles.dashboardDsAdHocFiltering = originalToggleValue;
+ });
+
+ it('should apply basic filtering end-to-end through public query method', async () => {
+ const testFrame = createTestFrame([
+ { name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Bob'] },
+ { name: 'age', type: FieldType.number, values: [25, 30, 35] },
+ ]);
+
+ const scene = new SceneFlexLayout({
+ children: [
+ new SceneFlexItem({
+ body: new VizPanel({
+ key: getVizPanelKeyForPanelId(1),
+ $data: new SceneDataNode({
+ data: {
+ series: [testFrame],
+ state: LoadingState.Done,
+ timeRange: getDefaultTimeRange(),
+ },
+ }),
+ }),
+ }),
+ ],
+ });
+
+ const ds = new DashboardDatasource({} as DataSourceInstanceSettings);
+ const filters: AdHocVariableFilter[] = [{ key: 'name', operator: '=', value: 'John' }];
+
+ const observable = ds.query(createQueryRequest(filters, scene));
+
+ let result: DataQueryResponse | undefined;
+ observable.subscribe({ next: (data) => (result = data) });
+
+ expect(result?.data[0].fields[0].values).toEqual(['John']);
+ expect(result?.data[0].fields[1].values).toEqual([25]);
+ expect(result?.data[0].length).toBe(1);
+ });
+
+ it('should respect feature toggle and not filter when disabled', async () => {
+ // Temporarily disable the feature toggle for this test
+ config.featureToggles.dashboardDsAdHocFiltering = false;
+
+ const testFrame = createTestFrame([
+ { name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Bob'] },
+ { name: 'age', type: FieldType.number, values: [25, 30, 35] },
+ ]);
+
+ const scene = new SceneFlexLayout({
+ children: [
+ new SceneFlexItem({
+ body: new VizPanel({
+ key: getVizPanelKeyForPanelId(1),
+ $data: new SceneDataNode({
+ data: {
+ series: [testFrame],
+ state: LoadingState.Done,
+ timeRange: getDefaultTimeRange(),
+ },
+ }),
+ }),
+ }),
+ ],
+ });
+
+ const ds = new DashboardDatasource({} as DataSourceInstanceSettings);
+ const filters: AdHocVariableFilter[] = [{ key: 'name', operator: '=', value: 'John' }];
+
+ const observable = ds.query(createQueryRequest(filters, scene));
+
+ let result: DataQueryResponse | undefined;
+ observable.subscribe({ next: (data) => (result = data) });
+
+ // Should return unfiltered data since feature toggle is disabled
+ expect(result?.data[0].fields[0].values).toEqual(['John', 'Jane', 'Bob']);
+ expect(result?.data[0].fields[1].values).toEqual([25, 30, 35]);
+ expect(result?.data[0].length).toBe(3);
+ });
+
+ it('should apply multiple filters with AND logic through public API', async () => {
+ const testFrame = createTestFrame([
+ { name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Bob'] },
+ { name: 'status', type: FieldType.string, values: ['active', 'active', 'inactive'] },
+ ]);
+
+ const scene = new SceneFlexLayout({
+ children: [
+ new SceneFlexItem({
+ body: new VizPanel({
+ key: getVizPanelKeyForPanelId(1),
+ $data: new SceneDataNode({
+ data: {
+ series: [testFrame],
+ state: LoadingState.Done,
+ timeRange: getDefaultTimeRange(),
+ },
+ }),
+ }),
+ }),
+ ],
+ });
+
+ const ds = new DashboardDatasource({} as DataSourceInstanceSettings);
+ const filters: AdHocVariableFilter[] = [
+ { key: 'name', operator: '!=', value: 'John' },
+ { key: 'status', operator: '=', value: 'active' },
+ ];
+
+ const observable = ds.query(createQueryRequest(filters, scene));
+
+ let result: DataQueryResponse | undefined;
+ observable.subscribe({ next: (data) => (result = data) });
+
+ expect(result?.data[0].fields[0].values).toEqual(['Jane']);
+ expect(result?.data[0].fields[1].values).toEqual(['active']);
+ expect(result?.data[0].length).toBe(1);
+ });
+ });
+
+ // Now test AdHoc filtering with unit-tests to test all aspects of behaviour.
+ // Here we test a private method, which allows for smaller, simpler,
+ // faster tests.
+ // This is the bottom of the 'Test Pyramid'
+ describe('Algorithm (but by testing a Private Method)', () => {
+ const ds = new DashboardDatasource({} as DataSourceInstanceSettings);
+
+ // Type-safe interface for accessing private methods in tests
+ interface DashboardDatasourceWithPrivateMethods {
+ applyAdHocFilters: (frame: DataFrame, filters: AdHocVariableFilter[]) => DataFrame;
+ }
+
+ it('should apply equality filter correctly', () => {
+ const frame = createTestFrame([
+ { name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Bob'] },
+ { name: 'age', type: FieldType.number, values: [25, 30, 35] },
+ ]);
+
+ const result = (ds as unknown as DashboardDatasourceWithPrivateMethods).applyAdHocFilters(frame, [
+ { key: 'name', operator: '=', value: 'John' },
+ ]);
+
+ expect(result.fields[0].values).toEqual(['John']);
+ expect(result.fields[1].values).toEqual([25]);
+ expect(result.length).toBe(1);
+ });
+
+ it('should apply not-equal filter correctly', () => {
+ const frame = createTestFrame([
+ { name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Bob'] },
+ { name: 'age', type: FieldType.number, values: [25, 30, 35] },
+ ]);
+
+ const result = (ds as unknown as DashboardDatasourceWithPrivateMethods).applyAdHocFilters(frame, [
+ { key: 'name', operator: '!=', value: 'John' },
+ ]);
+
+ expect(result.fields[0].values).toEqual(['Jane', 'Bob']);
+ expect(result.fields[1].values).toEqual([30, 35]);
+ expect(result.length).toBe(2);
+ });
+
+ it('should apply multiple filters with AND logic', () => {
+ const frame = createTestFrame([
+ { name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Bob'] },
+ { name: 'status', type: FieldType.string, values: ['active', 'active', 'inactive'] },
+ ]);
+
+ const result = (ds as unknown as DashboardDatasourceWithPrivateMethods).applyAdHocFilters(frame, [
+ { key: 'name', operator: '!=', value: 'John' },
+ { key: 'status', operator: '=', value: 'active' },
+ ]);
+
+ expect(result.fields[0].values).toEqual(['Jane']);
+ expect(result.fields[1].values).toEqual(['active']);
+ expect(result.length).toBe(1);
+ });
+
+ it('should handle null values correctly', () => {
+ const frame = createTestFrame([
+ { name: 'name', type: FieldType.string, values: ['John', null, 'Bob'] },
+ { name: 'age', type: FieldType.number, values: [25, 30, 35] },
+ ]);
+
+ const result = (ds as unknown as DashboardDatasourceWithPrivateMethods).applyAdHocFilters(frame, [
+ { key: 'name', operator: '!=', value: 'John' },
+ ]);
+
+ expect(result.fields[0].values).toEqual([null, 'Bob']);
+ expect(result.fields[1].values).toEqual([30, 35]);
+ expect(result.length).toBe(2);
+ });
+
+ it('should apply equality filter on numeric fields', () => {
+ const frame = createTestFrame([
+ { name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Bob'] },
+ { name: 'age', type: FieldType.number, values: [25, 30, 35] },
+ ]);
+
+ const result = (ds as unknown as DashboardDatasourceWithPrivateMethods).applyAdHocFilters(frame, [
+ { key: 'age', operator: '=', value: '30' },
+ ]);
+
+ expect(result.fields[0].values).toEqual(['Jane']);
+ expect(result.fields[1].values).toEqual([30]);
+ expect(result.length).toBe(1);
+ });
+
+ it('should apply not-equal filter on numeric fields', () => {
+ const frame = createTestFrame([
+ { name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Bob'] },
+ { name: 'age', type: FieldType.number, values: [25, 30, 35] },
+ ]);
+
+ const result = (ds as unknown as DashboardDatasourceWithPrivateMethods).applyAdHocFilters(frame, [
+ { key: 'age', operator: '!=', value: '30' },
+ ]);
+
+ expect(result.fields[0].values).toEqual(['John', 'Bob']);
+ expect(result.fields[1].values).toEqual([25, 35]);
+ expect(result.length).toBe(2);
+ });
+
+ it('should handle numeric fields with null values', () => {
+ const frame = createTestFrame([
+ { name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Bob'] },
+ { name: 'age', type: FieldType.number, values: [25, null, 35] },
+ ]);
+
+ const result = (ds as unknown as DashboardDatasourceWithPrivateMethods).applyAdHocFilters(frame, [
+ { key: 'age', operator: '!=', value: '25' },
+ ]);
+
+ expect(result.fields[0].values).toEqual(['Jane', 'Bob']);
+ expect(result.fields[1].values).toEqual([null, 35]);
+ expect(result.length).toBe(2);
+ });
+
+ it('should handle mixed string and numeric filtering', () => {
+ const frame = createTestFrame([
+ { name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Bob', 'Alice'] },
+ { name: 'age', type: FieldType.number, values: [25, 30, 25, 35] },
+ ]);
+
+ const result = (ds as unknown as DashboardDatasourceWithPrivateMethods).applyAdHocFilters(frame, [
+ { key: 'name', operator: '!=', value: 'Bob' },
+ { key: 'age', operator: '=', value: '25' },
+ ]);
+
+ // Should match: name != 'Bob' AND age = 25
+ // John: !Bob + 25 ✓
+ // Jane: !Bob + 30 ✗
+ // Bob: Bob + 25 ✗
+ // Alice: !Bob + 35 ✗
+ expect(result.fields[0].values).toEqual(['John']);
+ expect(result.fields[1].values).toEqual([25]);
+ expect(result.length).toBe(1);
+ });
+
+ it('should handle floating point numbers correctly', () => {
+ const frame = createTestFrame([
+ { name: 'name', type: FieldType.string, values: ['Alice', 'Bob', 'Charlie', 'Diana'] },
+ { name: 'score', type: FieldType.number, values: [95.5, 87.25, 95.5, 92.0] },
+ ]);
+
+ const result = (ds as unknown as DashboardDatasourceWithPrivateMethods).applyAdHocFilters(frame, [
+ { key: 'score', operator: '=', value: '95.5' },
+ ]);
+
+ expect(result.fields[0].values).toEqual(['Alice', 'Charlie']);
+ expect(result.fields[1].values).toEqual([95.5, 95.5]);
+ expect(result.length).toBe(2);
+ });
+
+ it('should handle floating point numbers with != operator', () => {
+ const frame = createTestFrame([
+ { name: 'name', type: FieldType.string, values: ['Alice', 'Bob', 'Charlie'] },
+ { name: 'temperature', type: FieldType.number, values: [98.6, 99.1, 98.6] },
+ ]);
+
+ const result = (ds as unknown as DashboardDatasourceWithPrivateMethods).applyAdHocFilters(frame, [
+ { key: 'temperature', operator: '!=', value: '98.6' },
+ ]);
+
+ expect(result.fields[0].values).toEqual(['Bob']);
+ expect(result.fields[1].values).toEqual([99.1]);
+ expect(result.length).toBe(1);
+ });
+
+ it('should handle precision edge cases with floats', () => {
+ const frame = createTestFrame([
+ { name: 'name', type: FieldType.string, values: ['Test1', 'Test2', 'Test3'] },
+ { name: 'value', type: FieldType.number, values: [0.1 + 0.2, 0.3, 1.0000001] },
+ ]);
+
+ // Note: 0.1 + 0.2 !== 0.3 in JavaScript due to floating point precision
+ const result = (ds as unknown as DashboardDatasourceWithPrivateMethods).applyAdHocFilters(frame, [
+ { key: 'value', operator: '=', value: '0.3' },
+ ]);
+
+ // Should only match the exact 0.3, not the computed 0.1 + 0.2
+ expect(result.fields[0].values).toEqual(['Test2']);
+ expect(result.fields[1].values).toEqual([0.3]);
+ expect(result.length).toBe(1);
+ });
+
+ it('should handle empty data frames', () => {
+ const frame = createTestFrame([
+ { name: 'name', type: FieldType.string, values: [] },
+ { name: 'age', type: FieldType.number, values: [] },
+ ]);
+
+ const result = (ds as unknown as DashboardDatasourceWithPrivateMethods).applyAdHocFilters(frame, [
+ { key: 'name', operator: '=', value: 'John' },
+ ]);
+
+ expect(result.fields[0].values).toEqual([]);
+ expect(result.fields[1].values).toEqual([]);
+ expect(result.length).toBe(0);
+ });
+
+ it.skip('should handle remaining operators', () => {
+ // Not yet implemented, so we explicitly don't specify any behaviour for this
+ });
+
+ it.skip('should handle remaining field types (eg. date)', () => {
+ // Not yet implemented, so we explicitly don't specify any behaviour for this
+ });
+
+ it('should handle filters on missing fields with = operator', () => {
+ const frame = createTestFrame([
+ { name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Bob'] },
+ { name: 'age', type: FieldType.number, values: [25, 30, 35] },
+ ]);
+
+ const result = (ds as unknown as DashboardDatasourceWithPrivateMethods).applyAdHocFilters(frame, [
+ { key: 'missing_field', operator: '=', value: 'test' },
+ ]);
+
+ // Should return empty result since field doesn't exist
+ expect(result.fields[0].values).toEqual([]);
+ expect(result.fields[1].values).toEqual([]);
+ expect(result.length).toBe(0);
+ });
+
+ it('should handle filters on missing fields with != operator', () => {
+ const frame = createTestFrame([
+ { name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Bob'] },
+ { name: 'age', type: FieldType.number, values: [25, 30, 35] },
+ ]);
+
+ const result = (ds as unknown as DashboardDatasourceWithPrivateMethods).applyAdHocFilters(frame, [
+ { key: 'missing_field', operator: '!=', value: 'test' },
+ ]);
+
+ // Should return all rows since field doesn't exist (all rows are "not equal")
+ expect(result.fields[0].values).toEqual(['John', 'Jane', 'Bob']);
+ expect(result.fields[1].values).toEqual([25, 30, 35]);
+ expect(result.length).toBe(3);
+ });
+
+ it('should handle complex filtering scenario', () => {
+ const frame = createTestFrame([
+ { name: 'name', type: FieldType.string, values: ['John', 'Jane', 'Admin', 'Bob'] },
+ { name: 'status', type: FieldType.string, values: ['active', 'inactive', 'active', 'active'] },
+ { name: 'age', type: FieldType.number, values: [25, 30, 35, 40] },
+ ]);
+
+ const result = (ds as unknown as DashboardDatasourceWithPrivateMethods).applyAdHocFilters(frame, [
+ { key: 'status', operator: '=', value: 'active' },
+ { key: 'name', operator: '!=', value: 'Admin' },
+ { key: 'missing_field', operator: '!=', value: 'ignored' }, // Should be ignored
+ ]);
+
+ // Should match: status=active AND name!=Admin
+ // John: active + !Admin ✓
+ // Jane: inactive + !Admin ✗
+ // Admin: active + Admin ✗
+ // Bob: active + !Admin ✓
+ expect(result.fields[0].values).toEqual(['John', 'Bob']);
+ expect(result.fields[1].values).toEqual(['active', 'active']);
+ expect(result.fields[2].values).toEqual([25, 40]);
+ expect(result.length).toBe(2);
+ });
+ });
+ });
});
function setup(query: DashboardQuery, requestId?: string) {
@@ -140,7 +579,6 @@ function setup(query: DashboardQuery, requestId?: string) {
series: [arrayToDataFrame([1, 2, 3])],
state: LoadingState.Done,
timeRange: getDefaultTimeRange(),
- structureRev: 11,
},
}),
transformations: [{ id: 'reduce', options: {} }],
diff --git a/public/app/plugins/datasource/dashboard/datasource.ts b/public/app/plugins/datasource/dashboard/datasource.ts
index a9e34586d4f..e98e140eb0a 100644
--- a/public/app/plugins/datasource/dashboard/datasource.ts
+++ b/public/app/plugins/datasource/dashboard/datasource.ts
@@ -12,7 +12,13 @@ import {
DataFrame,
LoadingState,
Field,
+ FieldType,
+ AdHocVariableFilter,
+ MetricFindValue,
+ getValueMatcher,
+ ValueMatcherID,
} from '@grafana/data';
+import { config } from '@grafana/runtime';
import { SceneDataProvider, SceneDataTransformer, SceneObject } from '@grafana/scenes';
import {
activateSceneObjectAndParentTree,
@@ -71,6 +77,9 @@ export class DashboardDatasource extends DataSourceApi {
return of({ data: [] });
}
+ // Extract AdHoc filters from the request
+ const adHocFilters = options.filters || [];
+
return defer(() => {
if (!sourceDataProvider!.isActive && sourceDataProvider?.setContainerWidth) {
sourceDataProvider?.setContainerWidth(500);
@@ -82,7 +91,7 @@ export class DashboardDatasource extends DataSourceApi {
debounceTime(50),
map((result) => {
return {
- data: this.getDataFramesForQueryTopic(result.data, query),
+ data: this.getDataFramesForQueryTopic(result.data, query, adHocFilters),
state: result.data.state,
errors: result.data.errors,
error: result.data.error,
@@ -95,7 +104,11 @@ export class DashboardDatasource extends DataSourceApi {
});
}
- private getDataFramesForQueryTopic(data: PanelData, query: DashboardQuery): DataFrame[] {
+ private getDataFramesForQueryTopic(
+ data: PanelData,
+ query: DashboardQuery,
+ filters: AdHocVariableFilter[]
+ ): DataFrame[] {
const annotations = data.annotations ?? [];
if (query.topic === DataTopic.Annotations) {
return annotations.map((frame) => ({
@@ -111,6 +124,13 @@ export class DashboardDatasource extends DataSourceApi {
...s,
fields: s.fields.map((field: Field) => ({
...field,
+ config: {
+ ...field.config,
+ // Enable AdHoc filtering for string and numeric fields only when feature toggle is enabled
+ filterable: config.featureToggles.dashboardDsAdHocFiltering
+ ? field.type === FieldType.string || field.type === FieldType.number
+ : field.config.filterable,
+ },
state: {
...field.state,
},
@@ -118,10 +138,149 @@ export class DashboardDatasource extends DataSourceApi {
};
});
- return [...series, ...annotations];
+ if (!config.featureToggles.dashboardDsAdHocFiltering || filters.length === 0) {
+ return [...series, ...annotations];
+ }
+
+ // Apply AdHoc filters to series data
+ const filteredSeries = series.map((frame) => this.applyAdHocFilters(frame, filters));
+ return [...filteredSeries, ...annotations];
}
}
+ /**
+ * Apply AdHoc filters to a DataFrame
+ * Optimized version with pre-computed field indices and value matchers for better performance
+ */
+ private applyAdHocFilters(frame: DataFrame, filters: AdHocVariableFilter[]): DataFrame {
+ if (filters.length === 0 || frame.length === 0) {
+ 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;
+ });
+
+ // If no filters remain after optimization, return original frame
+ if (filterFieldIndices.length === 0) {
+ return frame;
+ }
+
+ // Short-circuit: if any filter has '=' operator with missing field, reject all rows
+ const hasImpossibleFilter = filterFieldIndices.some(({ fieldIndex }) => fieldIndex === -1);
+ if (hasImpossibleFilter) {
+ return this.reconstructDataFrame(frame);
+ }
+
+ const matchingRows = new Set();
+
+ // 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 field = frame.fields[fieldIndex];
+
+ // Use Grafana's value matcher system
+ return matcher?.(rowIndex, field, frame, [frame]) ?? false;
+ });
+
+ if (rowMatches) {
+ matchingRows.add(rowIndex);
+ }
+ }
+
+ // Early return if no filtering occurred
+ if (matchingRows.size === frame.length) {
+ return frame;
+ }
+
+ return this.reconstructDataFrame(frame, matchingRows);
+ }
+
+ /**
+ * 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
+ if (fieldIndex === -1) {
+ return null;
+ }
+
+ const field = frame.fields[fieldIndex];
+
+ // Only support string and numeric fields when feature toggle is enabled
+ if (config.featureToggles.dashboardDsAdHocFiltering) {
+ if (field.type !== FieldType.string && field.type !== FieldType.number) {
+ return null;
+ }
+ }
+
+ // Map operator to matcher ID
+ let matcherId: ValueMatcherID;
+ switch (filter.operator) {
+ case '=':
+ matcherId = ValueMatcherID.equal;
+ break;
+ case '!=':
+ matcherId = ValueMatcherID.notEqual;
+ break;
+ default:
+ return null; // Unknown operator
+ }
+
+ try {
+ return getValueMatcher({
+ id: matcherId,
+ options: { value: filter.value },
+ });
+ } catch (error) {
+ console.warn('Failed to create value matcher for filter:', filter, error);
+ return null;
+ }
+ }
+
+ /**
+ * Reconstruct DataFrame with only matching rows
+ * Optimized to avoid repeated array operations
+ */
+ private reconstructDataFrame(frame: DataFrame, matchingRows?: Set): DataFrame {
+ // Default to empty set if no matching rows provided (reject all rows)
+ const rows = matchingRows ?? new Set();
+
+ const fields: Field[] = frame.fields.map((field) => {
+ // Pre-allocate array and use direct assignment for better performance with large datasets
+ const newValues = new Array(rows.size);
+ let i = 0;
+ for (const rowIndex of rows) {
+ newValues[i++] = field.values[rowIndex];
+ }
+
+ return {
+ ...field,
+ values: newValues,
+ state: {}, // Clean the state as it's being recalculated
+ };
+ });
+
+ return {
+ ...frame,
+ fields: fields,
+ length: rows.size,
+ };
+ }
+
private findSourcePanel(scene: SceneObject, panelId: number) {
// We're trying to find the original panel, not a cloned one, since `panelId` alone cannot resolve clones
return findOriginalVizPanelByKey(scene, getVizPanelKeyForPanelId(panelId));
@@ -169,4 +328,10 @@ export class DashboardDatasource extends DataSourceApi {
testDatasource(): Promise {
return Promise.resolve({ message: '', status: '' });
}
+
+ getTagKeys(): Promise {
+ // Stub implementation to indicate AdHoc filter support
+ // Full implementation will be added in future PRs
+ return Promise.resolve([]);
+ }
}