Ad-Hoc Filters & Scopes: don't remap one-of to regex in frontend (#92995)

* send "one-of" and "not-one-of" directly to datasource (instead of changing them to regex)
* Added to Ad-hoc and and Scope Filters: The "values" prop ([]string) and the "one-of" and "not-one-"of" operators. "values" is used with one-of and not-one-of. 
* adds prometheus support for the above 


---------

Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com>
Co-authored-by: Todd Treece <todd.treece@grafana.com>
This commit is contained in:
Kyle Brandt 2024-09-09 09:56:43 -04:00 committed by GitHub
parent 9c7029fa3e
commit b89f3f8115
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 119 additions and 33 deletions

View File

@ -17,18 +17,22 @@ export interface ScopeDashboardBinding {
status: ScopeDashboardBindingStatus; status: ScopeDashboardBindingStatus;
} }
export type ScopeFilterOperator = 'equals' | 'not-equals' | 'regex-match' | 'regex-not-match'; export type ScopeFilterOperator = 'equals' | 'not-equals' | 'regex-match' | 'regex-not-match' | 'one-of' | 'not-one-of';
export const scopeFilterOperatorMap: Record<string, ScopeFilterOperator> = { export const scopeFilterOperatorMap: Record<string, ScopeFilterOperator> = {
'=': 'equals', '=': 'equals',
'!=': 'not-equals', '!=': 'not-equals',
'=~': 'regex-match', '=~': 'regex-match',
'!~': 'regex-not-match', '!~': 'regex-not-match',
'=|': 'one-of',
'!=|': 'not-one-of',
}; };
export interface ScopeSpecFilter { export interface ScopeSpecFilter {
key: string; key: string;
value: string; value: string;
// values is used for operators that support multiple values (e.g. one-of, not-one-of)
values?: string[];
operator: ScopeFilterOperator; operator: ScopeFilterOperator;
} }

View File

@ -1275,6 +1275,8 @@ describe('modifyQuery', () => {
}, },
{ key: 'reg', value: 'regv', operator: '=~' }, { key: 'reg', value: 'regv', operator: '=~' },
{ key: 'nreg', value: 'nregv', operator: '!~' }, { key: 'nreg', value: 'nregv', operator: '!~' },
{ key: 'foo', value: 'bar', operator: '=|' },
{ key: 'bar', value: 'baz', operator: '!=|' },
]; ];
const expectedScopeFilter: ScopeSpecFilter[] = [ const expectedScopeFilter: ScopeSpecFilter[] = [
{ key: 'eq', value: 'eqv', operator: 'equals' }, { key: 'eq', value: 'eqv', operator: 'equals' },
@ -1285,6 +1287,8 @@ describe('modifyQuery', () => {
}, },
{ key: 'reg', value: 'regv', operator: 'regex-match' }, { key: 'reg', value: 'regv', operator: 'regex-match' },
{ key: 'nreg', value: 'nregv', operator: 'regex-not-match' }, { key: 'nreg', value: 'nregv', operator: 'regex-not-match' },
{ key: 'foo', value: 'bar', operator: 'one-of' },
{ key: 'bar', value: 'baz', operator: 'not-one-of' },
]; ];
const result = ds.generateScopeFilters(adhocFilter); const result = ds.generateScopeFilters(adhocFilter);
result.forEach((r, i) => { result.forEach((r, i) => {

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(remapOneOf).map((f) => ({ const labelFilters: QueryBuilderLabelFilter[] = options.filters.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(remapOneOf).map((f) => ({ const labelFilters: QueryBuilderLabelFilter[] = options.filters.map((f) => ({
label: f.key, label: f.key,
value: f.value, value: f.value,
op: f.operator, op: f.operator,
@ -822,10 +822,11 @@ export class PrometheusDatasource
return []; return [];
} }
return filters.map(remapOneOf).map((f) => ({ return filters.map((f) => ({
...f, key: f.key,
value: this.templateSrv.replace(f.value, {}, this.interpolateQueryExpr),
operator: scopeFilterOperatorMap[f.operator], operator: scopeFilterOperatorMap[f.operator],
value: this.templateSrv.replace(f.value, {}, this.interpolateQueryExpr),
values: f.values?.map((v) => this.templateSrv.replace(v, {}, this.interpolateQueryExpr)),
})); }));
} }
@ -834,7 +835,7 @@ export class PrometheusDatasource
return expr; return expr;
} }
const finalQuery = filters.map(remapOneOf).reduce((acc, filter) => { const finalQuery = filters.reduce((acc, filter) => {
const { key, operator } = filter; const { key, operator } = filter;
let { value } = filter; let { value } = filter;
if (operator === '=~' || operator === '!~') { if (operator === '=~' || operator === '!~') {
@ -1001,19 +1002,3 @@ 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

@ -26,8 +26,10 @@ type ScopeSpec struct {
} }
type ScopeFilter struct { type ScopeFilter struct {
Key string `json:"key"` Key string `json:"key"`
Value string `json:"value"` Value string `json:"value"`
// Values is used for operators that require multiple values (e.g. one-of and not-one-of).
Values []string `json:"values,omitempty"`
Operator FilterOperator `json:"operator"` Operator FilterOperator `json:"operator"`
} }
@ -41,6 +43,8 @@ const (
FilterOperatorNotEquals FilterOperator = "not-equals" FilterOperatorNotEquals FilterOperator = "not-equals"
FilterOperatorRegexMatch FilterOperator = "regex-match" FilterOperatorRegexMatch FilterOperator = "regex-match"
FilterOperatorRegexNotMatch FilterOperator = "regex-not-match" FilterOperatorRegexNotMatch FilterOperator = "regex-not-match"
FilterOperatorOneOf FilterOperator = "one-of"
FilterOperatorNotOneOf FilterOperator = "not-one-of"
) )
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

View File

@ -219,6 +219,11 @@ func (in *ScopeDashboardBindingStatus) DeepCopy() *ScopeDashboardBindingStatus {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ScopeFilter) DeepCopyInto(out *ScopeFilter) { func (in *ScopeFilter) DeepCopyInto(out *ScopeFilter) {
*out = *in *out = *in
if in.Values != nil {
in, out := &in.Values, &out.Values
*out = make([]string, len(*in))
copy(*out, *in)
}
return return
} }
@ -347,7 +352,9 @@ func (in *ScopeSpec) DeepCopyInto(out *ScopeSpec) {
if in.Filters != nil { if in.Filters != nil {
in, out := &in.Filters, &out.Filters in, out := &in.Filters, &out.Filters
*out = make([]ScopeFilter, len(*in)) *out = make([]ScopeFilter, len(*in))
copy(*out, *in) for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
} }
return return
} }

View File

@ -387,13 +387,28 @@ func schema_pkg_apis_scope_v0alpha1_ScopeFilter(ref common.ReferenceCallback) co
Format: "", Format: "",
}, },
}, },
"values": {
SchemaProps: spec.SchemaProps{
Description: "Values is used for operators that require multiple values (e.g. one-of and not-one-of).",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
},
},
"operator": { "operator": {
SchemaProps: spec.SchemaProps{ SchemaProps: spec.SchemaProps{
Description: "Possible enum values:\n - `\"equals\"`\n - `\"not-equals\"`\n - `\"regex-match\"`\n - `\"regex-not-match\"`", Description: "Possible enum values:\n - `\"equals\"`\n - `\"not-equals\"`\n - `\"not-one-of\"`\n - `\"one-of\"`\n - `\"regex-match\"`\n - `\"regex-not-match\"`",
Default: "", Default: "",
Type: []string{"string"}, Type: []string{"string"},
Format: "", Format: "",
Enum: []interface{}{"equals", "not-equals", "regex-match", "regex-not-match"}, Enum: []interface{}{"equals", "not-equals", "not-one-of", "one-of", "regex-match", "regex-not-match"},
}, },
}, },
}, },

View File

@ -1,3 +1,4 @@
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/scope/v0alpha1,FindScopeDashboardBindingsResults,Items API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/scope/v0alpha1,FindScopeDashboardBindingsResults,Items
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/scope/v0alpha1,ScopeDashboardBindingStatus,Groups API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/scope/v0alpha1,ScopeDashboardBindingStatus,Groups
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/scope/v0alpha1,ScopeFilter,Values
API rule violation: names_match,github.com/grafana/grafana/pkg/apis/scope/v0alpha1,ScopeNodeSpec,LinkID API rule violation: names_match,github.com/grafana/grafana/pkg/apis/scope/v0alpha1,ScopeNodeSpec,LinkID

View File

@ -89,8 +89,10 @@ type ScopeSpec struct {
// ScopeFilter is a hand copy of the ScopeFilter struct from pkg/apis/scope/v0alpha1/types.go // ScopeFilter is a hand copy of the ScopeFilter struct from pkg/apis/scope/v0alpha1/types.go
// to avoid import (temp fix) // to avoid import (temp fix)
type ScopeFilter struct { type ScopeFilter struct {
Key string `json:"key"` Key string `json:"key"`
Value string `json:"value"` Value string `json:"value"`
// Values is used for operators that require multiple values (e.g. one-of and not-one-of).
Values []string `json:"values,omitempty"`
Operator FilterOperator `json:"operator"` Operator FilterOperator `json:"operator"`
} }
@ -103,6 +105,8 @@ const (
FilterOperatorNotEquals FilterOperator = "not-equals" FilterOperatorNotEquals FilterOperator = "not-equals"
FilterOperatorRegexMatch FilterOperator = "regex-match" FilterOperatorRegexMatch FilterOperator = "regex-match"
FilterOperatorRegexNotMatch FilterOperator = "regex-not-match" FilterOperatorRegexNotMatch FilterOperator = "regex-not-match"
FilterOperatorOneOf FilterOperator = "one-of"
FilterOperatorNotOneOf FilterOperator = "not-one-of"
) )
// Internal interval and range variables // Internal interval and range variables

View File

@ -34,6 +34,13 @@
}, },
"value": { "value": {
"type": "string" "type": "string"
},
"values": {
"description": "Values is used for operators that require multiple values (e.g. one-of and not-one-of).",
"type": "array",
"items": {
"type": "string"
}
} }
}, },
"additionalProperties": false "additionalProperties": false
@ -213,6 +220,13 @@
}, },
"value": { "value": {
"type": "string" "type": "string"
},
"values": {
"description": "Values is used for operators that require multiple values (e.g. one-of and not-one-of).",
"type": "array",
"items": {
"type": "string"
}
} }
}, },
"additionalProperties": false "additionalProperties": false

View File

@ -44,6 +44,13 @@
}, },
"value": { "value": {
"type": "string" "type": "string"
},
"values": {
"description": "Values is used for operators that require multiple values (e.g. one-of and not-one-of).",
"type": "array",
"items": {
"type": "string"
}
} }
}, },
"additionalProperties": false "additionalProperties": false
@ -223,6 +230,13 @@
}, },
"value": { "value": {
"type": "string" "type": "string"
},
"values": {
"description": "Values is used for operators that require multiple values (e.g. one-of and not-one-of).",
"type": "array",
"items": {
"type": "string"
}
} }
}, },
"additionalProperties": false "additionalProperties": false

View File

@ -8,7 +8,7 @@
{ {
"metadata": { "metadata": {
"name": "default", "name": "default",
"resourceVersion": "1715871691891", "resourceVersion": "1725885733879",
"creationTimestamp": "2024-03-25T13:19:04Z" "creationTimestamp": "2024-03-25T13:19:04Z"
}, },
"spec": { "spec": {
@ -31,6 +31,13 @@
}, },
"value": { "value": {
"type": "string" "type": "string"
},
"values": {
"description": "Values is used for operators that require multiple values (e.g. one-of and not-one-of).",
"items": {
"type": "string"
},
"type": "array"
} }
}, },
"required": [ "required": [
@ -117,6 +124,13 @@
}, },
"value": { "value": {
"type": "string" "type": "string"
},
"values": {
"description": "Values is used for operators that require multiple values (e.g. one-of and not-one-of).",
"items": {
"type": "string"
},
"type": "array"
} }
}, },
"required": [ "required": [

View File

@ -2,11 +2,13 @@ package models
import ( import (
"fmt" "fmt"
"strings"
"github.com/prometheus/prometheus/model/labels" "github.com/prometheus/prometheus/model/labels"
"github.com/prometheus/prometheus/promql/parser" "github.com/prometheus/prometheus/promql/parser"
) )
// ApplyFiltersAndGroupBy takes a raw promQL expression, converts the filters into PromQL matchers, and applies these matchers to the parsed expression. It also applies the group by clause to any aggregate expressions in the parsed expression.
func ApplyFiltersAndGroupBy(rawExpr string, scopeFilters, adHocFilters []ScopeFilter, groupBy []string) (string, error) { func ApplyFiltersAndGroupBy(rawExpr string, scopeFilters, adHocFilters []ScopeFilter, groupBy []string) (string, error) {
expr, err := parser.ParseExpr(rawExpr) expr, err := parser.ParseExpr(rawExpr)
if err != nil { if err != nil {
@ -98,8 +100,17 @@ func filterToMatcher(f ScopeFilter) (*labels.Matcher, error) {
mt = labels.MatchRegexp mt = labels.MatchRegexp
case FilterOperatorRegexNotMatch: case FilterOperatorRegexNotMatch:
mt = labels.MatchNotRegexp mt = labels.MatchNotRegexp
case FilterOperatorOneOf:
mt = labels.MatchRegexp
case FilterOperatorNotOneOf:
mt = labels.MatchNotRegexp
default: default:
return nil, fmt.Errorf("unknown operator %q", f.Operator) return nil, fmt.Errorf("unknown operator %q", f.Operator)
} }
if f.Operator == FilterOperatorOneOf || f.Operator == FilterOperatorNotOneOf {
if len(f.Values) > 0 {
return labels.NewMatcher(mt, f.Key, strings.Join(f.Values, "|"))
}
}
return labels.NewMatcher(mt, f.Key, f.Value) return labels.NewMatcher(mt, f.Key, f.Value)
} }

View File

@ -68,7 +68,7 @@ func TestApplyQueryFiltersAndGroupBy_Filters(t *testing.T) {
expectErr: false, expectErr: false,
}, },
{ {
name: "Adhoc and Scope filter conflict - adhoc wins", name: "Adhoc and Scope filter conflict - adhoc wins (if not oneOf or notOneOf)",
query: `http_requests_total{job="prometheus"}`, query: `http_requests_total{job="prometheus"}`,
scopeFilters: []ScopeFilter{ scopeFilters: []ScopeFilter{
{Key: "status", Value: "404", Operator: FilterOperatorEquals}, {Key: "status", Value: "404", Operator: FilterOperatorEquals},
@ -88,6 +88,15 @@ func TestApplyQueryFiltersAndGroupBy_Filters(t *testing.T) {
expected: `capacity_bytes{job="alloy"} + available_bytes{job="alloy"} / 1024`, expected: `capacity_bytes{job="alloy"} + available_bytes{job="alloy"} / 1024`,
expectErr: false, expectErr: false,
}, },
{
name: "OneOf Operator is combined into a single regex filter",
query: `http_requests_total{job="prometheus"}`,
scopeFilters: []ScopeFilter{
{Key: "status", Values: []string{"404", "400"}, Operator: FilterOperatorOneOf},
},
expected: `http_requests_total{job="prometheus",status=~"404|400"}`,
expectErr: false,
},
} }
for _, tt := range tests { for _, tt := range tests {
@ -98,7 +107,7 @@ func TestApplyQueryFiltersAndGroupBy_Filters(t *testing.T) {
require.Error(t, err) require.Error(t, err)
} else { } else {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, expr, tt.expected) require.Equal(t, tt.expected, expr)
} }
}) })
} }