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 (
+
+
+
+
+
+ {isRange ? (
+ <>
+ 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