AdHocFilters: Add support for new isOneOf multi value operator (#91837)

* handle oneOf operator in prometheus

* use new supportsMultiValueOperators

* remap oneOf to regex in prometheus datasource

* Remap one of operators for scope filters

* use plugin.json property instead of feature toggle

* optional chaining

* fix unit tests

* use getInstanceSettings

* update to latest scenes

* fix unit tests

---------

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
Ashley Harrison
2024-08-30 11:03:44 +01:00
committed by GitHub
parent 2e451b2ed7
commit c16cc488c9
16 changed files with 81 additions and 35 deletions

View File

@ -308,6 +308,10 @@
"type": "boolean", "type": "boolean",
"description": "For data source plugins, if the plugin supports metric queries. Used to enable the plugin in the panel editor." "description": "For data source plugins, if the plugin supports metric queries. Used to enable the plugin in the panel editor."
}, },
"multiValueFilterOperators": {
"type": "boolean",
"description": "For data source plugins, if the plugin supports multi value operators in adhoc filters."
},
"pascalName": { "pascalName": {
"type": "string", "type": "string",
"description": "[internal only] The PascalCase name for the plugin. Used for creating machine-friendly identifiers, typically in code generation. If not provided, defaults to name, but title-cased and sanitized (only alphabetical characters allowed).", "description": "[internal only] The PascalCase name for the plugin. Used for creating machine-friendly identifiers, typically in code generation. If not provided, defaults to name, but title-cased and sanitized (only alphabetical characters allowed).",

View File

@ -268,7 +268,7 @@
"@grafana/prometheus": "workspace:*", "@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*", "@grafana/runtime": "workspace:*",
"@grafana/saga-icons": "workspace:*", "@grafana/saga-icons": "workspace:*",
"@grafana/scenes": "^5.10.1", "@grafana/scenes": "^5.11.0",
"@grafana/schema": "workspace:*", "@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*", "@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*", "@grafana/ui": "workspace:*",

View File

@ -142,6 +142,7 @@ export interface DataSourcePluginMeta<T extends KeyValue = {}> extends PluginMet
unlicensed?: boolean; unlicensed?: boolean;
backend?: boolean; backend?: boolean;
isBackend?: boolean; isBackend?: boolean;
multiValueFilterOperators?: boolean;
} }
interface PluginMetaQueryOptions { interface PluginMetaQueryOptions {

View File

@ -53,6 +53,7 @@ export interface AdHocVariableFilter {
key: string; key: string;
operator: string; operator: string;
value: string; value: string;
values?: string[];
/** @deprecated */ /** @deprecated */
condition?: string; condition?: string;
} }

View File

@ -603,7 +603,7 @@ export class PrometheusDatasource
return this.languageProvider.getLabelKeys().map((k) => ({ value: k, text: k })); return this.languageProvider.getLabelKeys().map((k) => ({ value: k, text: k }));
} }
const labelFilters: QueryBuilderLabelFilter[] = options.filters.map((f) => ({ const labelFilters: QueryBuilderLabelFilter[] = options.filters.map(remapOneOf).map((f) => ({
label: f.key, label: f.key,
value: f.value, value: f.value,
op: f.operator, op: f.operator,
@ -620,7 +620,7 @@ export class PrometheusDatasource
// By implementing getTagKeys and getTagValues we add ad-hoc filters functionality // By implementing getTagKeys and getTagValues we add ad-hoc filters functionality
async getTagValues(options: DataSourceGetTagValuesOptions<PromQuery>) { async getTagValues(options: DataSourceGetTagValuesOptions<PromQuery>) {
const labelFilters: QueryBuilderLabelFilter[] = options.filters.map((f) => ({ const labelFilters: QueryBuilderLabelFilter[] = options.filters.map(remapOneOf).map((f) => ({
label: f.key, label: f.key,
value: f.value, value: f.value,
op: f.operator, op: f.operator,
@ -822,7 +822,7 @@ export class PrometheusDatasource
return []; return [];
} }
return filters.map((f) => ({ return filters.map(remapOneOf).map((f) => ({
...f, ...f,
value: this.templateSrv.replace(f.value, {}, this.interpolateQueryExpr), value: this.templateSrv.replace(f.value, {}, this.interpolateQueryExpr),
operator: scopeFilterOperatorMap[f.operator], operator: scopeFilterOperatorMap[f.operator],
@ -834,7 +834,7 @@ export class PrometheusDatasource
return expr; return expr;
} }
const finalQuery = filters.reduce((acc, filter) => { const finalQuery = filters.map(remapOneOf).reduce((acc, filter) => {
const { key, operator } = filter; const { key, operator } = filter;
let { value } = filter; let { value } = filter;
if (operator === '=~' || operator === '!~') { if (operator === '=~' || operator === '!~') {
@ -1001,3 +1001,19 @@ export function prometheusRegularEscape<T>(value: T) {
export function prometheusSpecialRegexEscape<T>(value: T) { export function prometheusSpecialRegexEscape<T>(value: T) {
return typeof value === 'string' ? value.replace(/\\/g, '\\\\\\\\').replace(/[$^*{}\[\]\'+?.()|]/g, '\\\\$&') : value; return typeof value === 'string' ? value.replace(/\\/g, '\\\\\\\\').replace(/[$^*{}\[\]\'+?.()|]/g, '\\\\$&') : value;
} }
export function remapOneOf(filter: AdHocVariableFilter) {
let { operator, value, values } = filter;
if (operator === '=|') {
operator = '=~';
value = values?.map(prometheusRegularEscape).join('|') ?? '';
} else if (operator === '!=|') {
operator = '!~';
value = values?.map(prometheusRegularEscape).join('|') ?? '';
}
return {
...filter,
operator,
value,
};
}

View File

@ -454,6 +454,7 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug
Module: plugin.Module, Module: plugin.Module,
BaseURL: plugin.BaseURL, BaseURL: plugin.BaseURL,
Angular: plugin.Angular, Angular: plugin.Angular,
MultiValueFilterOperators: plugin.MultiValueFilterOperators,
} }
if ds.JsonData == nil { if ds.JsonData == nil {

View File

@ -171,13 +171,11 @@ type Signature struct {
type PluginMetaDTO struct { type PluginMetaDTO struct {
JSONData JSONData
Signature SignatureStatus `json:"signature"` Signature SignatureStatus `json:"signature"`
Module string `json:"module"` Module string `json:"module"`
BaseURL string `json:"baseUrl"` BaseURL string `json:"baseUrl"`
Angular AngularMeta `json:"angular"` Angular AngularMeta `json:"angular"`
MultiValueFilterOperators bool `json:"multiValueFilterOperators"`
} }
type DataSourceDTO struct { type DataSourceDTO struct {

View File

@ -124,6 +124,7 @@ type JSONData struct {
Mixed bool `json:"mixed,omitempty"` Mixed bool `json:"mixed,omitempty"`
Streaming bool `json:"streaming"` Streaming bool `json:"streaming"`
SDK bool `json:"sdk,omitempty"` SDK bool `json:"sdk,omitempty"`
MultiValueFilterOperators bool `json:"multiValueFilterOperators,omitempty"`
// Backend (Datasource + Renderer + SecretsManager) // Backend (Datasource + Renderer + SecretsManager)
Executable string `json:"executable,omitempty"` Executable string `json:"executable,omitempty"`

View File

@ -1,4 +1,5 @@
import { AnnotationChangeEvent, AnnotationEventUIModel, CoreApp, DataFrame } from '@grafana/data'; import { AnnotationChangeEvent, AnnotationEventUIModel, CoreApp, DataFrame } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { AdHocFiltersVariable, dataLayers, sceneGraph, sceneUtils, VizPanel } from '@grafana/scenes'; import { AdHocFiltersVariable, dataLayers, sceneGraph, sceneUtils, VizPanel } from '@grafana/scenes';
import { DataSourceRef } from '@grafana/schema'; import { DataSourceRef } from '@grafana/schema';
import { AdHocFilterItem, PanelContext } from '@grafana/ui'; import { AdHocFilterItem, PanelContext } from '@grafana/ui';
@ -160,6 +161,7 @@ export function getAdHocFilterVariableFor(scene: DashboardScene, ds: DataSourceR
const newVariable = new AdHocFiltersVariable({ const newVariable = new AdHocFiltersVariable({
name: 'Filters', name: 'Filters',
datasource: ds, datasource: ds,
supportsMultiValueOperators: Boolean(getDataSourceSrv().getInstanceSettings(ds)?.meta.multiValueFilterOperators),
useQueriesAsFilterForOptions: true, useQueriesAsFilterForOptions: true,
}); });

View File

@ -136,6 +136,8 @@ jest.mock('@grafana/runtime', () => ({
toDataQuery: (q: StandardVariableQuery) => q, toDataQuery: (q: StandardVariableQuery) => q,
}, },
}), }),
// mock getInstanceSettings()
getInstanceSettings: jest.fn(),
}), }),
getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => { getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => {
return runRequestMock(ds, request); return runRequestMock(ds, request);

View File

@ -28,6 +28,7 @@ export function AdHocFiltersVariableEditor(props: AdHocFiltersVariableEditorProp
variable.setState({ variable.setState({
datasource: dsRef, datasource: dsRef,
supportsMultiValueOperators: ds.meta.multiValueFilterOperators,
}); });
}; };

View File

@ -26,6 +26,14 @@ import { NEW_LINK } from '../settings/links/utils';
import { createSceneVariableFromVariableModel, createVariablesForSnapshot } from './variables'; import { createSceneVariableFromVariableModel, createVariablesForSnapshot } from './variables';
// mock getDataSourceSrv.getInstanceSettings()
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => ({
getInstanceSettings: jest.fn(),
}),
}));
describe('when creating variables objects', () => { describe('when creating variables objects', () => {
it('should migrate custom variable', () => { it('should migrate custom variable', () => {
const variable: CustomVariableModel = { const variable: CustomVariableModel = {
@ -425,6 +433,7 @@ describe('when creating variables objects', () => {
datasource: { uid: 'gdev-prometheus', type: 'prometheus' }, datasource: { uid: 'gdev-prometheus', type: 'prometheus' },
applyMode: 'auto', applyMode: 'auto',
useQueriesAsFilterForOptions: true, useQueriesAsFilterForOptions: true,
supportsMultiValueOperators: false,
}); });
}); });
@ -508,6 +517,7 @@ describe('when creating variables objects', () => {
}, },
], ],
useQueriesAsFilterForOptions: true, useQueriesAsFilterForOptions: true,
supportsMultiValueOperators: false,
}); });
}); });

View File

@ -1,5 +1,5 @@
import { TypedVariableModel } from '@grafana/data'; import { TypedVariableModel } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config, getDataSourceSrv } from '@grafana/runtime';
import { import {
AdHocFiltersVariable, AdHocFiltersVariable,
ConstantVariable, ConstantVariable,
@ -56,6 +56,9 @@ export function createVariablesForSnapshot(oldModel: DashboardModel) {
baseFilters: v.baseFilters ?? [], baseFilters: v.baseFilters ?? [],
defaultKeys: v.defaultKeys, defaultKeys: v.defaultKeys,
useQueriesAsFilterForOptions: true, useQueriesAsFilterForOptions: true,
supportsMultiValueOperators: Boolean(
getDataSourceSrv().getInstanceSettings(v.datasource)?.meta.multiValueFilterOperators
),
}); });
} }
// for other variable types we are using the SnapshotVariable // for other variable types we are using the SnapshotVariable
@ -133,6 +136,9 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode
baseFilters: variable.baseFilters ?? [], baseFilters: variable.baseFilters ?? [],
defaultKeys: variable.defaultKeys, defaultKeys: variable.defaultKeys,
useQueriesAsFilterForOptions: true, useQueriesAsFilterForOptions: true,
supportsMultiValueOperators: Boolean(
getDataSourceSrv().getInstanceSettings(variable.datasource)?.meta.multiValueFilterOperators
),
}); });
} }
if (variable.type === 'custom') { if (variable.type === 'custom') {

View File

@ -262,6 +262,8 @@ function getVariableSet(initialDS?: string, metric?: string, initialFilters?: Ad
layout: 'vertical', layout: 'vertical',
filters: initialFilters ?? [], filters: initialFilters ?? [],
baseFilters: getBaseFiltersForMetric(metric), baseFilters: getBaseFiltersForMetric(metric),
// since we only support prometheus datasources, this is always true
supportsMultiValueOperators: true,
}), }),
], ],
}); });

View File

@ -89,6 +89,7 @@
"queryOptions": { "queryOptions": {
"minInterval": true "minInterval": true
}, },
"multiValueFilterOperators": true,
"info": { "info": {
"description": "Open source time series database & alerting", "description": "Open source time series database & alerting",
"author": { "author": {

View File

@ -3918,9 +3918,9 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft linkType: soft
"@grafana/scenes@npm:^5.10.1": "@grafana/scenes@npm:^5.11.0":
version: 5.10.2 version: 5.11.0
resolution: "@grafana/scenes@npm:5.10.2" resolution: "@grafana/scenes@npm:5.11.0"
dependencies: dependencies:
"@grafana/e2e-selectors": "npm:^11.0.0" "@grafana/e2e-selectors": "npm:^11.0.0"
"@leeoniya/ufuzzy": "npm:^1.0.14" "@leeoniya/ufuzzy": "npm:^1.0.14"
@ -3935,7 +3935,7 @@ __metadata:
"@grafana/ui": ">=10.4" "@grafana/ui": ">=10.4"
react: ^18.0.0 react: ^18.0.0
react-dom: ^18.0.0 react-dom: ^18.0.0
checksum: 10/f7409cc8b7d3687baba7d5af307fd3f836579f20a71e0a782841ba7031d786a2b039757d0e89822af650e825c6b6102571b524a7f24179002da02d2eeaa3cc8b checksum: 10/4b56c0c831468651f75992979820541c07d3e9cfcb2547c58aed647a60ad02bd8d81b68d233c2ecc401f5b6400e7c29cbe9408f6f5e374bb30161d47ab0f08e2
languageName: node languageName: node
linkType: hard linkType: hard
@ -18495,7 +18495,7 @@ __metadata:
"@grafana/prometheus": "workspace:*" "@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*" "@grafana/runtime": "workspace:*"
"@grafana/saga-icons": "workspace:*" "@grafana/saga-icons": "workspace:*"
"@grafana/scenes": "npm:^5.10.1" "@grafana/scenes": "npm:^5.11.0"
"@grafana/schema": "workspace:*" "@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*" "@grafana/sql": "workspace:*"
"@grafana/tsconfig": "npm:^2.0.0" "@grafana/tsconfig": "npm:^2.0.0"