diff --git a/pkg/expr/commands.go b/pkg/expr/commands.go index 33052cec2db..2f32ed37760 100644 --- a/pkg/expr/commands.go +++ b/pkg/expr/commands.go @@ -289,6 +289,8 @@ const ( TypeResample // TypeClassicConditions is the CMDType for the classic condition operation. TypeClassicConditions + // TypeThreshold is the CMDType for checking if a threshold has been crossed + TypeThreshold ) func (gt CommandType) String() string { @@ -317,6 +319,8 @@ func ParseCommandType(s string) (CommandType, error) { return TypeResample, nil case "classic_conditions": return TypeClassicConditions, nil + case "threshold": + return TypeThreshold, nil default: return TypeUnknown, fmt.Errorf("'%v' is not a recognized expression type", s) } diff --git a/pkg/expr/nodes.go b/pkg/expr/nodes.go index 333cd1bb9c6..2bf89a0ff11 100644 --- a/pkg/expr/nodes.go +++ b/pkg/expr/nodes.go @@ -119,6 +119,8 @@ func buildCMDNode(dp *simple.DirectedGraph, rn *rawNode) (*CMDNode, error) { node.Command, err = UnmarshalResampleCommand(rn) case TypeClassicConditions: node.Command, err = classic.UnmarshalConditionsCmd(rn.Query, rn.RefID) + case TypeThreshold: + node.Command, err = UnmarshalThresholdCommand(rn) default: return nil, fmt.Errorf("expression command type '%v' in expression '%v' not implemented", commandType, rn.RefID) } diff --git a/pkg/expr/threshold.go b/pkg/expr/threshold.go new file mode 100644 index 00000000000..b3ffa819153 --- /dev/null +++ b/pkg/expr/threshold.go @@ -0,0 +1,131 @@ +package expr + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/grafana/grafana/pkg/expr/mathexp" +) + +type ThresholdCommand struct { + ReferenceVar string + RefID string + ThresholdFunc string + Conditions []float64 +} + +const ( + ThresholdIsAbove = "gt" + ThresholdIsBelow = "lt" + ThresholdIsWithinRange = "within_range" + ThresholdIsOutsideRange = "outside_range" +) + +var ( + supportedThresholdFuncs = []string{ThresholdIsAbove, ThresholdIsBelow, ThresholdIsWithinRange, ThresholdIsOutsideRange} +) + +func NewThresholdCommand(refID, referenceVar, thresholdFunc string, conditions []float64) (*ThresholdCommand, error) { + return &ThresholdCommand{ + RefID: refID, + ReferenceVar: referenceVar, + ThresholdFunc: thresholdFunc, + Conditions: conditions, + }, nil +} + +type ThresholdConditionJSON struct { + Evaluator ConditionEvalJSON `json:"evaluator"` +} + +type ConditionEvalJSON struct { + Params []float64 `json:"params"` + Type string `json:"type"` // e.g. "gt" +} + +// UnmarshalResampleCommand creates a ResampleCMD from Grafana's frontend query. +func UnmarshalThresholdCommand(rn *rawNode) (*ThresholdCommand, error) { + rawQuery := rn.Query + + rawExpression, ok := rawQuery["expression"] + if !ok { + return nil, fmt.Errorf("no variable specified to reference for refId %v", rn.RefID) + } + referenceVar, ok := rawExpression.(string) + if !ok { + return nil, fmt.Errorf("expected threshold variable to be a string, got %T for refId %v", rawExpression, rn.RefID) + } + + jsonFromM, err := json.Marshal(rawQuery["conditions"]) + if err != nil { + return nil, fmt.Errorf("failed to remarshal threshold expression body: %w", err) + } + var conditions []ThresholdConditionJSON + if err = json.Unmarshal(jsonFromM, &conditions); err != nil { + return nil, fmt.Errorf("failed to unmarshal remarshaled threshold expression body: %w", err) + } + + for _, condition := range conditions { + if !IsSupportedThresholdFunc(condition.Evaluator.Type) { + return nil, fmt.Errorf("expected threshold function to be one of %s, got %s", strings.Join(supportedThresholdFuncs, ", "), condition.Evaluator.Type) + } + } + + // we only support one condition for now, we might want to turn this in to "OR" expressions later + if len(conditions) != 1 { + return nil, fmt.Errorf("threshold expression requires exactly one condition") + } + firstCondition := conditions[0] + + return NewThresholdCommand(rn.RefID, referenceVar, firstCondition.Evaluator.Type, firstCondition.Evaluator.Params) +} + +// NeedsVars returns the variable names (refIds) that are dependencies +// to execute the command and allows the command to fulfill the Command interface. +func (tc *ThresholdCommand) NeedsVars() []string { + return []string{tc.ReferenceVar} +} + +func (tc *ThresholdCommand) Execute(ctx context.Context, vars mathexp.Vars) (mathexp.Results, error) { + mathExpression, err := createMathExpression(tc.ReferenceVar, tc.ThresholdFunc, tc.Conditions) + if err != nil { + return mathexp.Results{}, err + } + + mathCommand, err := NewMathCommand(tc.ReferenceVar, mathExpression) + if err != nil { + return mathexp.Results{}, err + } + + return mathCommand.Execute(ctx, vars) +} + +// createMathExpression converts all the info we have about a "threshold" expression in to a Math expression +func createMathExpression(referenceVar string, thresholdFunc string, args []float64) (string, error) { + switch thresholdFunc { + case ThresholdIsAbove: + return fmt.Sprintf("${%s} > %f", referenceVar, args[0]), nil + case ThresholdIsBelow: + return fmt.Sprintf("${%s} < %f", referenceVar, args[0]), nil + case ThresholdIsWithinRange: + return fmt.Sprintf("${%s} > %f && ${%s} < %f", referenceVar, args[0], referenceVar, args[1]), nil + case ThresholdIsOutsideRange: + return fmt.Sprintf("${%s} < %f || ${%s} > %f", referenceVar, args[0], referenceVar, args[1]), nil + default: + return "", fmt.Errorf("failed to evaluate threshold expression: no such threshold function %s", thresholdFunc) + } +} + +func IsSupportedThresholdFunc(name string) bool { + isSupported := false + + for _, funcName := range supportedThresholdFuncs { + if funcName == name { + isSupported = true + } + } + + return isSupported +} diff --git a/pkg/expr/threshold_test.go b/pkg/expr/threshold_test.go new file mode 100644 index 00000000000..0b3717760dd --- /dev/null +++ b/pkg/expr/threshold_test.go @@ -0,0 +1,212 @@ +package expr + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewThresholdCommand(t *testing.T) { + cmd, err := NewThresholdCommand("B", "A", "is_above", []float64{}) + require.Nil(t, err) + require.NotNil(t, cmd) +} + +func TestUnmarshalThresholdCommand(t *testing.T) { + type testCase struct { + description string + query string + shouldError bool + expectedError string + } + + cases := []testCase{ + { + description: "unmarshal proper object", + query: `{ + "expression" : "A", + "type": "threshold", + "conditions": [{ + "evaluator": { + "type": "gt", + "params": [20, 80] + } + }] + }`, + shouldError: false, + }, + { + description: "unmarshal with missing conditions should error", + query: `{ + "expression" : "A", + "type": "threshold", + "conditions": [] + }`, + shouldError: true, + expectedError: "requires exactly one condition", + }, + { + description: "unmarshal with missing conditions should error", + query: `{ + "expression" : "A", + "type": "threshold", + "conditions": [] + }`, + shouldError: true, + expectedError: "requires exactly one condition", + }, + { + description: "unmarshal with unsupported threshold function", + query: `{ + "expression" : "A", + "type": "threshold", + "conditions": [{ + "evaluator": { + "type": "foo", + "params": [20, 80] + } + }] + }`, + shouldError: true, + expectedError: "expected threshold function to be one of", + }, + { + description: "unmarshal with bad expression", + query: `{ + "expression" : 0, + "type": "threshold", + "conditions": [] + }`, + shouldError: true, + expectedError: "expected threshold variable to be a string", + }, + } + + for _, tc := range cases { + q := []byte(tc.query) + + var qmap = make(map[string]interface{}) + require.NoError(t, json.Unmarshal(q, &qmap)) + + cmd, err := UnmarshalThresholdCommand(&rawNode{ + RefID: "", + Query: qmap, + QueryType: "", + TimeRange: TimeRange{}, + DataSource: nil, + }) + + if tc.shouldError { + require.Nil(t, cmd) + require.NotNil(t, err) + require.Contains(t, err.Error(), tc.expectedError) + } else { + require.Nil(t, err) + require.NotNil(t, cmd) + } + } +} + +func TestThresholdCommandVars(t *testing.T) { + cmd, err := NewThresholdCommand("B", "A", "is_above", []float64{}) + require.Nil(t, err) + require.Equal(t, cmd.NeedsVars(), []string{"A"}) +} + +func TestCreateMathExpression(t *testing.T) { + type testCase struct { + description string + expected string + + ref string + function string + params []float64 + } + + cases := []testCase{ + { + description: "is above", + ref: "My Ref", + function: "gt", + params: []float64{0}, + expected: "${My Ref} > 0.000000", + }, + { + description: "is below", + ref: "A", + function: "lt", + params: []float64{0}, + expected: "${A} < 0.000000", + }, + { + description: "is within", + ref: "B", + function: "within_range", + params: []float64{20, 80}, + expected: "${B} > 20.000000 && ${B} < 80.000000", + }, + { + description: "is outside", + ref: "B", + function: "outside_range", + params: []float64{20, 80}, + expected: "${B} < 20.000000 || ${B} > 80.000000", + }, + } + + for _, tc := range cases { + t.Run(tc.description, func(t *testing.T) { + expr, err := createMathExpression(tc.ref, tc.function, tc.params) + + require.Nil(t, err) + require.NotNil(t, expr) + + require.Equal(t, expr, tc.expected) + }) + } + + t.Run("should error if function is unsupported", func(t *testing.T) { + expr, err := createMathExpression("A", "foo", []float64{0}) + require.Equal(t, expr, "") + require.NotNil(t, err) + require.Contains(t, err.Error(), "no such threshold function") + }) +} + +func TestIsSupportedThresholdFunc(t *testing.T) { + type testCase struct { + function string + supported bool + } + + cases := []testCase{ + { + function: ThresholdIsAbove, + supported: true, + }, + { + function: ThresholdIsBelow, + supported: true, + }, + { + function: ThresholdIsWithinRange, + supported: true, + }, + { + function: ThresholdIsOutsideRange, + supported: true, + }, + { + function: "foo", + supported: false, + }, + } + + for _, tc := range cases { + t.Run(tc.function, func(t *testing.T) { + supported := IsSupportedThresholdFunc(tc.function) + require.Equal(t, supported, tc.supported) + }) + } +} diff --git a/public/app/features/alerting/unified/utils/timeRange.ts b/public/app/features/alerting/unified/utils/timeRange.ts index 10091b2c987..dd7b3370bd1 100644 --- a/public/app/features/alerting/unified/utils/timeRange.ts +++ b/public/app/features/alerting/unified/utils/timeRange.ts @@ -32,6 +32,7 @@ const getReferencedIds = (model: ExpressionQuery, queries: AlertQuery[]): string return getReferencedIdsForMath(model, queries); case ExpressionQueryType.resample: case ExpressionQueryType.reduce: + case ExpressionQueryType.threshold: return getReferencedIdsForReduce(model); } }; diff --git a/public/app/features/expressions/ExpressionQueryEditor.tsx b/public/app/features/expressions/ExpressionQueryEditor.tsx index 66c21d60ccd..a72b25f10ed 100644 --- a/public/app/features/expressions/ExpressionQueryEditor.tsx +++ b/public/app/features/expressions/ExpressionQueryEditor.tsx @@ -7,6 +7,7 @@ import { ClassicConditions } from './components/ClassicConditions'; import { Math } from './components/Math'; import { Reduce } from './components/Reduce'; import { Resample } from './components/Resample'; +import { Threshold } from './components/Threshold'; import { ExpressionQuery, ExpressionQueryType, gelTypes } from './types'; import { getDefaults } from './utils/expressionTypes'; @@ -25,6 +26,7 @@ function useExpressionsCache() { case ExpressionQueryType.math: case ExpressionQueryType.reduce: case ExpressionQueryType.resample: + case ExpressionQueryType.threshold: return expressionCache.current[queryType]; case ExpressionQueryType.classic: return undefined; @@ -37,11 +39,13 @@ function useExpressionsCache() { expressionCache.current.math = value; break; - // We want to use the same value for Reduce and Resample + // We want to use the same value for Reduce, Resample and Threshold case ExpressionQueryType.reduce: + case ExpressionQueryType.resample: case ExpressionQueryType.resample: expressionCache.current.reduce = value; expressionCache.current.resample = value; + expressionCache.current.threshold = value; break; } }, []); @@ -82,6 +86,9 @@ export function ExpressionQueryEditor(props: Props) { case ExpressionQueryType.classic: return ; + + case ExpressionQueryType.threshold: + return ; } }; diff --git a/public/app/features/expressions/components/Threshold.tsx b/public/app/features/expressions/components/Threshold.tsx new file mode 100644 index 00000000000..8575bdc27bf --- /dev/null +++ b/public/app/features/expressions/components/Threshold.tsx @@ -0,0 +1,148 @@ +import { css } from '@emotion/css'; +import React, { FC, FormEvent } from 'react'; + +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { ButtonSelect, InlineField, InlineFieldRow, Input, Select, useStyles2 } from '@grafana/ui'; +import { EvalFunction } from 'app/features/alerting/state/alertDef'; + +import { ClassicCondition, ExpressionQuery, thresholdFunctions } from '../types'; + +interface Props { + labelWidth: number; + refIds: Array>; + query: ExpressionQuery; + onChange: (query: ExpressionQuery) => void; +} + +const defaultThresholdFunction = EvalFunction.IsAbove; + +export const Threshold: FC = ({ labelWidth, onChange, refIds, query }) => { + const styles = useStyles2(getStyles); + + const defaultEvaluator: ClassicCondition = { + type: 'query', + evaluator: { + type: defaultThresholdFunction, + params: [0, 0], + }, + query: { + params: [], + }, + reducer: { + params: [], + type: 'last', + }, + }; + + const conditions = query.conditions?.length ? query.conditions : [defaultEvaluator]; + const condition = conditions[0]; + + const thresholdFunction = thresholdFunctions.find((fn) => fn.value === conditions[0].evaluator?.type); + + const onRefIdChange = (value: SelectableValue) => { + onChange({ ...query, expression: value.value }); + }; + + const onEvalFunctionChange = (value: SelectableValue) => { + const type = value.value ?? defaultThresholdFunction; + + onChange({ + ...query, + conditions: updateConditions(conditions, { type }), + }); + }; + + const onEvaluateValueChange = (event: FormEvent, index: number) => { + const newValue = parseFloat(event.currentTarget.value); + const newParams = [...condition.evaluator.params]; + newParams[index] = newValue; + + onChange({ + ...query, + conditions: updateConditions(conditions, { params: newParams }), + }); + }; + + const isRange = + condition.evaluator.type === EvalFunction.IsWithinRange || condition.evaluator.type === EvalFunction.IsOutsideRange; + + return ( + + + onEvaluateValueChange(event, 0)} + defaultValue={condition.evaluator.params[0]} + /> +
TO
+ onEvaluateValueChange(event, 1)} + defaultValue={condition.evaluator.params[1]} + /> + + ) : ( + onEvaluateValueChange(event, 0)} + defaultValue={conditions[0].evaluator.params[0] || 0} + /> + )} +
+ ); +}; + +function updateConditions( + conditions: ClassicCondition[], + update: Partial<{ + params: number[]; + type: EvalFunction; + }> +): ClassicCondition[] { + return [ + { + ...conditions[0], + evaluator: { + ...conditions[0].evaluator, + ...update, + }, + }, + ]; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + buttonSelectText: css` + color: ${theme.colors.primary.text}; + font-size: ${theme.typography.bodySmall.fontSize}; + text-transform: uppercase; + `, + button: css` + height: 32px; + + color: ${theme.colors.primary.text}; + font-size: ${theme.typography.bodySmall.fontSize}; + text-transform: uppercase; + + display: flex; + align-items: center; + border-radius: ${theme.shape.borderRadius(1)}; + font-weight: ${theme.typography.fontWeightBold}; + border: 1px solid ${theme.colors.border.medium}; + white-space: nowrap; + padding: 0 ${theme.spacing(1)}; + background-color: ${theme.colors.background.primary}; + `, +}); diff --git a/public/app/features/expressions/types.ts b/public/app/features/expressions/types.ts index d767347efcf..c3db7db9d6e 100644 --- a/public/app/features/expressions/types.ts +++ b/public/app/features/expressions/types.ts @@ -7,6 +7,7 @@ export enum ExpressionQueryType { reduce = 'reduce', resample = 'resample', classic = 'classic_conditions', + threshold = 'threshold', } export const gelTypes: Array> = [ @@ -14,6 +15,7 @@ export const gelTypes: Array> = [ { value: ExpressionQueryType.reduce, label: 'Reduce' }, { value: ExpressionQueryType.resample, label: 'Resample' }, { value: ExpressionQueryType.classic, label: 'Classic condition' }, + { value: ExpressionQueryType.threshold, label: 'Threshold' }, ]; export const reducerTypes: Array> = [ @@ -62,6 +64,13 @@ export const upsamplingTypes: Array> = [ { value: 'fillna', label: 'fillna', description: 'Fill with NaNs' }, ]; +export const thresholdFunctions: Array> = [ + { value: EvalFunction.IsAbove, label: 'Is above' }, + { value: EvalFunction.IsBelow, label: 'Is below' }, + { value: EvalFunction.IsWithinRange, label: 'Is within range' }, + { value: EvalFunction.IsOutsideRange, label: 'Is outside range' }, +]; + /** * For now this is a single object to cover all the types.... would likely * want to split this up by type as the complexity increases