Files
Sonia Aguilar 848eb01a89 Alerting: Fix broken UI because of query being optional for some ExpressionQuer… (#69650)
* Fix broken UI because of query being optional for some ExpressionQuery coditions field

* Delete query field from conditions in utils.test.ts
2023-06-07 10:58:08 +02:00

306 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ValidateResult } from 'react-hook-form';
import { DataFrame, ThresholdsConfig, ThresholdsMode, isTimeSeriesFrames, PanelData } from '@grafana/data';
import { GraphTresholdsStyleMode, LoadingState } from '@grafana/schema';
import { config } from 'app/core/config';
import { EvalFunction } from 'app/features/alerting/state/alertDef';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { ClassicCondition, ExpressionQueryType } from 'app/features/expressions/types';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { RuleFormType } from '../../types/rule-form';
import { createDagFromQueries, getOriginOfRefId } from './dag';
export function queriesWithUpdatedReferences(
queries: AlertQuery[],
previousRefId: string,
newRefId: string
): AlertQuery[] {
return queries.map((query) => {
if (previousRefId === newRefId) {
return query;
}
if (!isExpressionQuery(query.model)) {
return query;
}
const isMathExpression = query.model.type === 'math';
const isReduceExpression = query.model.type === 'reduce';
const isResampleExpression = query.model.type === 'resample';
const isClassicExpression = query.model.type === 'classic_conditions';
const isThresholdExpression = query.model.type === 'threshold';
if (isMathExpression) {
return {
...query,
model: {
...query.model,
expression: updateMathExpressionRefs(query.model.expression ?? '', previousRefId, newRefId),
},
};
}
if (isResampleExpression || isReduceExpression || isThresholdExpression) {
const isReferencing = query.model.expression === previousRefId;
return {
...query,
model: {
...query.model,
expression: isReferencing ? newRefId : query.model.expression,
},
};
}
if (isClassicExpression) {
const conditions = query.model.conditions?.map((condition) => ({
...condition,
query: {
...condition.query,
params: condition.query.params.map((param: string) => (param === previousRefId ? newRefId : param)),
},
}));
return { ...query, model: { ...query.model, conditions } };
}
return query;
});
}
export function updateMathExpressionRefs(expression: string, previousRefId: string, newRefId: string): string {
const oldExpression = new RegExp('(\\$' + previousRefId + '\\b)|(\\${' + previousRefId + '})', 'gm');
const newExpression = '${' + newRefId + '}';
return expression.replace(oldExpression, newExpression);
}
export function refIdExists(queries: AlertQuery[], refId: string | null): boolean {
return queries.find((query) => query.refId === refId) !== undefined;
}
// some gateways (like Istio) will decode "/" and "\" characters this will cause 404 errors for any API call
// that includes these values in the URL (ie. /my/path%2fto/resource -> /my/path/to/resource)
//
// see https://istio.io/latest/docs/ops/best-practices/security/#customize-your-system-on-path-normalization
export function checkForPathSeparator(value: string): ValidateResult {
const containsPathSeparator = value.includes('/') || value.includes('\\');
if (containsPathSeparator) {
return 'Cannot contain "/" or "\\" characters';
}
return true;
}
export function errorFromSeries(series: DataFrame[]): Error | undefined {
if (series.length === 0) {
return;
}
const isTimeSeriesResults = isTimeSeriesFrames(series);
let error;
if (isTimeSeriesResults) {
error = new Error('You cannot use time series data as an alert condition, consider adding a reduce expression.');
}
return error;
}
export function warningFromSeries(series: DataFrame[]): Error | undefined {
const notices = series[0]?.meta?.notices ?? [];
const warning = notices.find((notice) => notice.severity === 'warning')?.text;
return warning ? new Error(warning) : undefined;
}
export type ThresholdDefinition = {
config: ThresholdsConfig;
mode: GraphTresholdsStyleMode;
};
export type ThresholdDefinitions = Record<string, ThresholdDefinition>;
/**
* This function will retrieve threshold definitions for the given array of data and expression queries.
*/
export function getThresholdsForQueries(queries: AlertQuery[]) {
const thresholds: ThresholdDefinitions = {};
const SUPPORTED_EXPRESSION_TYPES = [ExpressionQueryType.threshold, ExpressionQueryType.classic];
for (const query of queries) {
if (!isExpressionQuery(query.model)) {
continue;
}
// currently only supporting "threshold" & "classic_condition" expressions
if (!SUPPORTED_EXPRESSION_TYPES.includes(query.model.type)) {
continue;
}
if (!Array.isArray(query.model.conditions)) {
continue;
}
// if any of the conditions are a "range" we switch to an "area" threshold view and ignore single threshold values
// the time series panel does not support both.
const hasRangeThreshold = query.model.conditions.some(isRangeCondition);
query.model.conditions.forEach((condition) => {
const threshold = condition.evaluator.params;
// "classic_conditions" use `condition.query.params[]` and "threshold" uses `query.model.expression`
const refId = condition.query?.params[0] ?? query.model.expression;
// if an expression hasn't been linked to a data query yet, it won't have a refId
if (!refId) {
return;
}
const isRangeThreshold = isRangeCondition(condition);
try {
// create a DAG so we can find the origin of the current expression
const graph = createDagFromQueries(queries);
const originRefIDs = getOriginOfRefId(refId, graph);
const originQueries = queries.filter((query) => originRefIDs.includes(query.refId));
originQueries.forEach((originQuery) => {
const originRefID = originQuery.refId;
// check if the origin is a data query
const originIsDataQuery = !isExpressionQuery(originQuery?.model);
// if yes, add threshold config to the refId of the data Query
const hasValidOrigin = Boolean(originIsDataQuery && originRefID);
// create the initial data structure for this origin refId
if (originRefID && !thresholds[originRefID]) {
thresholds[originRefID] = {
config: {
mode: ThresholdsMode.Absolute,
steps: [],
},
mode: GraphTresholdsStyleMode.Line,
};
}
if (originRefID && hasValidOrigin && !isRangeThreshold && !hasRangeThreshold) {
appendSingleThreshold(originRefID, threshold[0]);
} else if (originRefID && hasValidOrigin && isRangeThreshold) {
appendRangeThreshold(originRefID, threshold, condition.evaluator.type);
thresholds[originRefID].mode = GraphTresholdsStyleMode.LineAndArea;
}
});
} catch (err) {
console.error('Failed to parse thresholds', err);
return;
}
});
}
function appendSingleThreshold(refId: string, value: number): void {
thresholds[refId].config.steps.push(
...[
{
value: -Infinity,
color: 'transparent',
},
{
value: value,
color: config.theme2.colors.error.main,
},
]
);
}
function appendRangeThreshold(refId: string, values: number[], type: EvalFunction): void {
if (type === EvalFunction.IsWithinRange) {
thresholds[refId].config.steps.push(
...[
{
value: -Infinity,
color: 'transparent',
},
{
value: values[0],
color: config.theme2.colors.error.main,
},
{
value: values[1],
color: config.theme2.colors.error.main,
},
{
value: values[1],
color: 'transparent',
},
]
);
}
if (type === EvalFunction.IsOutsideRange) {
thresholds[refId].config.steps.push(
...[
{
value: -Infinity,
color: config.theme2.colors.error.main,
},
// we have to duplicate this value, or the graph will not display the handle in the right color
{
value: values[0],
color: config.theme2.colors.error.main,
},
{
value: values[0],
color: 'transparent',
},
{
value: values[1],
color: config.theme2.colors.error.main,
},
]
);
}
// now also sort the threshold values, if we don't then they will look weird in the time series panel
// TODO this doesn't work for negative values for now, those need to be sorted inverse
thresholds[refId].config.steps.sort((a, b) => a.value - b.value);
// also make sure we remove any "undefined" values from our steps in case the threshold config is incomplete
thresholds[refId].config.steps = thresholds[refId].config.steps.filter((step) => step.value !== undefined);
}
return thresholds;
}
function isRangeCondition(condition: ClassicCondition) {
return (
condition.evaluator.type === EvalFunction.IsWithinRange || condition.evaluator.type === EvalFunction.IsOutsideRange
);
}
export function getStatusMessage(data: PanelData): string | undefined {
const genericErrorMessage = 'Failed to fetch data';
if (data.state !== LoadingState.Error) {
return;
}
const errors = data.errors;
if (errors?.length) {
return errors.map((error) => error.message ?? genericErrorMessage).join(', ');
}
return data.error?.message ?? genericErrorMessage;
}
export function translateRouteParamToRuleType(param = ''): RuleFormType {
if (param === 'recording') {
return RuleFormType.cloudRecording;
}
return RuleFormType.grafana;
}