mirror of
https://github.com/grafana/grafana.git
synced 2025-02-03 12:11:09 -06:00
Alerting: Add threshold expression (#55102)
This commit is contained in:
parent
f7a3e50b23
commit
9aa61ddd0e
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
131
pkg/expr/threshold.go
Normal file
131
pkg/expr/threshold.go
Normal file
@ -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
|
||||
}
|
212
pkg/expr/threshold_test.go
Normal file
212
pkg/expr/threshold_test.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
@ -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 <ClassicConditions onChange={onChange} query={query} refIds={refIds} />;
|
||||
|
||||
case ExpressionQueryType.threshold:
|
||||
return <Threshold onChange={onChange} query={query} labelWidth={labelWidth} refIds={refIds} />;
|
||||
}
|
||||
};
|
||||
|
||||
|
148
public/app/features/expressions/components/Threshold.tsx
Normal file
148
public/app/features/expressions/components/Threshold.tsx
Normal file
@ -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<SelectableValue<string>>;
|
||||
query: ExpressionQuery;
|
||||
onChange: (query: ExpressionQuery) => void;
|
||||
}
|
||||
|
||||
const defaultThresholdFunction = EvalFunction.IsAbove;
|
||||
|
||||
export const Threshold: FC<Props> = ({ 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<string>) => {
|
||||
onChange({ ...query, expression: value.value });
|
||||
};
|
||||
|
||||
const onEvalFunctionChange = (value: SelectableValue<EvalFunction>) => {
|
||||
const type = value.value ?? defaultThresholdFunction;
|
||||
|
||||
onChange({
|
||||
...query,
|
||||
conditions: updateConditions(conditions, { type }),
|
||||
});
|
||||
};
|
||||
|
||||
const onEvaluateValueChange = (event: FormEvent<HTMLInputElement>, 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 (
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Input" labelWidth={labelWidth}>
|
||||
<Select onChange={onRefIdChange} options={refIds} value={query.expression} width={20} />
|
||||
</InlineField>
|
||||
<ButtonSelect
|
||||
className={styles.buttonSelectText}
|
||||
options={thresholdFunctions}
|
||||
onChange={onEvalFunctionChange}
|
||||
value={thresholdFunction}
|
||||
/>
|
||||
{isRange ? (
|
||||
<>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onChange={(event) => onEvaluateValueChange(event, 0)}
|
||||
defaultValue={condition.evaluator.params[0]}
|
||||
/>
|
||||
<div className={styles.button}>TO</div>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onChange={(event) => onEvaluateValueChange(event, 1)}
|
||||
defaultValue={condition.evaluator.params[1]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onChange={(event) => onEvaluateValueChange(event, 0)}
|
||||
defaultValue={conditions[0].evaluator.params[0] || 0}
|
||||
/>
|
||||
)}
|
||||
</InlineFieldRow>
|
||||
);
|
||||
};
|
||||
|
||||
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};
|
||||
`,
|
||||
});
|
@ -7,6 +7,7 @@ export enum ExpressionQueryType {
|
||||
reduce = 'reduce',
|
||||
resample = 'resample',
|
||||
classic = 'classic_conditions',
|
||||
threshold = 'threshold',
|
||||
}
|
||||
|
||||
export const gelTypes: Array<SelectableValue<ExpressionQueryType>> = [
|
||||
@ -14,6 +15,7 @@ export const gelTypes: Array<SelectableValue<ExpressionQueryType>> = [
|
||||
{ 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<SelectableValue<string>> = [
|
||||
@ -62,6 +64,13 @@ export const upsamplingTypes: Array<SelectableValue<string>> = [
|
||||
{ value: 'fillna', label: 'fillna', description: 'Fill with NaNs' },
|
||||
];
|
||||
|
||||
export const thresholdFunctions: Array<SelectableValue<EvalFunction>> = [
|
||||
{ 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
|
||||
|
Loading…
Reference in New Issue
Block a user