Alerting: Always show expression warnings and errors (#74839)

This commit is contained in:
Gilles De Mey
2023-09-14 15:05:28 +02:00
committed by GitHub
parent 5d88b8a4f5
commit af392c5886
8 changed files with 105 additions and 43 deletions

View File

@@ -4,7 +4,7 @@ import React, { FC, useCallback, useState } from 'react';
import { DataFrame, dateTimeFormat, GrafanaTheme2, isTimeSeriesFrames, LoadingState, PanelData } from '@grafana/data'; import { DataFrame, dateTimeFormat, GrafanaTheme2, isTimeSeriesFrames, LoadingState, PanelData } from '@grafana/data';
import { Stack } from '@grafana/experimental'; import { Stack } from '@grafana/experimental';
import { AutoSizeInput, Badge, Button, clearButtonStyles, IconButton, useStyles2 } from '@grafana/ui'; import { AutoSizeInput, Button, clearButtonStyles, IconButton, useStyles2 } from '@grafana/ui';
import { ClassicConditions } from 'app/features/expressions/components/ClassicConditions'; import { ClassicConditions } from 'app/features/expressions/components/ClassicConditions';
import { Math } from 'app/features/expressions/components/Math'; import { Math } from 'app/features/expressions/components/Math';
import { Reduce } from 'app/features/expressions/components/Reduce'; import { Reduce } from 'app/features/expressions/components/Reduce';
@@ -23,7 +23,7 @@ import { HoverCard } from '../HoverCard';
import { Spacer } from '../Spacer'; import { Spacer } from '../Spacer';
import { AlertStateTag } from '../rules/AlertStateTag'; import { AlertStateTag } from '../rules/AlertStateTag';
import { AlertConditionIndicator } from './AlertConditionIndicator'; import { ExpressionStatusIndicator } from './ExpressionStatusIndicator';
import { formatLabels, getSeriesLabels, getSeriesName, getSeriesValue, isEmptySeries } from './util'; import { formatLabels, getSeriesLabels, getSeriesName, getSeriesValue, isEmptySeries } from './util';
interface ExpressionProps { interface ExpressionProps {
@@ -302,15 +302,11 @@ const Header: FC<HeaderProps> = ({
<div>{getExpressionLabel(queryType)}</div> <div>{getExpressionLabel(queryType)}</div>
</Stack> </Stack>
<Spacer /> <Spacer />
{/* when we have an evaluation error, we show a badge next to "set as alert condition" */} <ExpressionStatusIndicator
{!alertCondition && error && (
<Badge color="red" icon="exclamation-circle" text="Error" tooltip={error.message} />
)}
<AlertConditionIndicator
onSetCondition={() => onSetCondition(query.refId)}
enabled={alertCondition}
error={error} error={error}
warning={warning} warning={warning}
onSetCondition={() => onSetCondition(query.refId)}
isCondition={alertCondition}
/> />
<IconButton <IconButton
name="trash-alt" name="trash-alt"

View File

@@ -0,0 +1,51 @@
import { screen, render } from '@testing-library/react';
import React from 'react';
import { ExpressionStatusIndicator } from './ExpressionStatusIndicator';
describe('ExpressionStatusIndicator', () => {
it('should render two elements when error and not condition', () => {
render(<ExpressionStatusIndicator isCondition={false} warning={new Error('this is a warning')} />);
expect(screen.getByText('Warning')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Set as alert condition' })).toBeInTheDocument();
});
it('should render one element when warning and condition', () => {
render(<ExpressionStatusIndicator isCondition warning={new Error('this is a warning')} />);
expect(screen.getByText('Alert condition')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Set as alert condition' })).not.toBeInTheDocument();
});
it('should render two elements when error and not condition', () => {
render(<ExpressionStatusIndicator isCondition={false} error={new Error('this is a error')} />);
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Set as alert condition' })).toBeInTheDocument();
});
it('should render one element when error and condition', () => {
render(<ExpressionStatusIndicator isCondition error={new Error('this is a error')} />);
expect(screen.getByText('Alert condition')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Set as alert condition' })).not.toBeInTheDocument();
});
it('should render one element if condition', () => {
render(<ExpressionStatusIndicator isCondition />);
expect(screen.queryByText('Error')).not.toBeInTheDocument();
expect(screen.queryByText('Warning')).not.toBeInTheDocument();
expect(screen.getByText('Alert condition')).toBeInTheDocument();
});
it('should render one element if not condition', () => {
render(<ExpressionStatusIndicator isCondition={false} />);
expect(screen.queryByText('Error')).not.toBeInTheDocument();
expect(screen.queryByText('Warning')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Alert condition' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Set as alert condition' })).toBeInTheDocument();
});
});

View File

@@ -5,36 +5,47 @@ import { GrafanaTheme2 } from '@grafana/data';
import { Badge, clearButtonStyles, useStyles2 } from '@grafana/ui'; import { Badge, clearButtonStyles, useStyles2 } from '@grafana/ui';
interface AlertConditionProps { interface AlertConditionProps {
enabled?: boolean;
error?: Error;
warning?: Error; warning?: Error;
error?: Error;
isCondition?: boolean;
onSetCondition?: () => void; onSetCondition?: () => void;
} }
export const AlertConditionIndicator = ({ enabled = false, error, warning, onSetCondition }: AlertConditionProps) => { export const ExpressionStatusIndicator = ({ error, warning, isCondition, onSetCondition }: AlertConditionProps) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
if (enabled && error) { const elements: JSX.Element[] = [];
if (error && isCondition) {
return <Badge color="red" icon="exclamation-circle" text="Alert condition" tooltip={error.message} />; return <Badge color="red" icon="exclamation-circle" text="Alert condition" tooltip={error.message} />;
} else if (error) {
elements.push(<Badge key="error" color="red" icon="exclamation-circle" text="Error" tooltip={error.message} />);
} }
if (enabled && warning) { if (warning && isCondition) {
return <Badge color="orange" icon="exclamation-triangle" text="Alert condition" tooltip={warning.message} />; return <Badge color="orange" icon="exclamation-triangle" text="Alert condition" tooltip={warning.message} />;
} else if (warning) {
elements.push(
<Badge key="warning" color="orange" icon="exclamation-triangle" text="Warning" tooltip={warning.message} />
);
} }
if (enabled && !error && !warning) { if (isCondition) {
return <Badge color="green" icon="check" text="Alert condition" />; elements.unshift(<Badge key="condition" color="green" icon="check" text="Alert condition" />);
} } else {
elements.unshift(
if (!enabled) { <button
return ( key="make-condition"
<button type="button" className={styles.actionLink} onClick={() => onSetCondition && onSetCondition()}> type="button"
className={styles.actionLink}
onClick={() => onSetCondition && onSetCondition()}
>
Set as alert condition Set as alert condition
</button> </button>
); );
} }
return null; return <>{elements}</>;
}; };
const getStyles = (theme: GrafanaTheme2) => { const getStyles = (theme: GrafanaTheme2) => {

View File

@@ -46,7 +46,7 @@ export const ExpressionsEditor = ({
const isAlertCondition = condition === query.refId; const isAlertCondition = condition === query.refId;
const error = data ? errorFromPreviewData(data) : undefined; const error = data ? errorFromPreviewData(data) : undefined;
const warning = isAlertCondition && data ? warningFromSeries(data.series) : undefined; const warning = data ? warningFromSeries(data.series) : undefined;
return ( return (
<Expression <Expression

View File

@@ -18,7 +18,7 @@ import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto';
import { AlertQueryOptions, EmptyQueryWrapper, QueryWrapper } from './QueryWrapper'; import { AlertQueryOptions, EmptyQueryWrapper, QueryWrapper } from './QueryWrapper';
import { errorFromPreviewData, getThresholdsForQueries } from './util'; import { errorFromCurrentCondition, errorFromPreviewData, getThresholdsForQueries } from './util';
interface Props { interface Props {
// The query configuration // The query configuration
@@ -156,12 +156,18 @@ export class QueryRows extends PureComponent<Props> {
<div ref={provided.innerRef} {...provided.droppableProps}> <div ref={provided.innerRef} {...provided.droppableProps}>
<Stack direction="column"> <Stack direction="column">
{queries.map((query, index) => { {queries.map((query, index) => {
const isCondition = this.props.condition === query.refId;
const data: PanelData = this.props.data?.[query.refId] ?? { const data: PanelData = this.props.data?.[query.refId] ?? {
series: [], series: [],
state: LoadingState.NotStarted, state: LoadingState.NotStarted,
}; };
const dsSettings = this.getDataSourceSettings(query); const dsSettings = this.getDataSourceSettings(query);
const error = data ? errorFromPreviewData(data) : undefined; let error: Error | undefined = undefined;
if (data && isCondition) {
error = errorFromCurrentCondition(data);
} else if (data) {
error = errorFromPreviewData(data);
}
if (!dsSettings) { if (!dsSettings) {
return ( return (

View File

@@ -14,12 +14,12 @@ import {
} from '@grafana/data'; } from '@grafana/data';
import { Stack } from '@grafana/experimental'; import { Stack } from '@grafana/experimental';
import { DataQuery } from '@grafana/schema'; import { DataQuery } from '@grafana/schema';
import { Badge, GraphTresholdsStyleMode, Icon, InlineField, Input, Tooltip, useStyles2 } from '@grafana/ui'; import { GraphTresholdsStyleMode, Icon, InlineField, Input, Tooltip, useStyles2 } from '@grafana/ui';
import { QueryEditorRow } from 'app/features/query/components/QueryEditorRow'; import { QueryEditorRow } from 'app/features/query/components/QueryEditorRow';
import { AlertQuery } from 'app/types/unified-alerting-dto'; import { AlertQuery } from 'app/types/unified-alerting-dto';
import { msToSingleUnitDuration } from '../../utils/time'; import { msToSingleUnitDuration } from '../../utils/time';
import { AlertConditionIndicator } from '../expressions/AlertConditionIndicator'; import { ExpressionStatusIndicator } from '../expressions/ExpressionStatusIndicator';
import { QueryOptions } from './QueryOptions'; import { QueryOptions } from './QueryOptions';
import { VizWrapper } from './VizWrapper'; import { VizWrapper } from './VizWrapper';
@@ -122,7 +122,7 @@ export const QueryWrapper = ({
const isAlertCondition = condition === query.refId; const isAlertCondition = condition === query.refId;
return ( return (
<Stack direction="row" alignItems="baseline" gap={1}> <Stack direction="row" alignItems="center" gap={1}>
<SelectingDataSourceTooltip /> <SelectingDataSourceTooltip />
<QueryOptions <QueryOptions
onChangeTimeRange={onChangeTimeRange} onChangeTimeRange={onChangeTimeRange}
@@ -131,15 +131,11 @@ export const QueryWrapper = ({
onChangeQueryOptions={onChangeQueryOptions} onChangeQueryOptions={onChangeQueryOptions}
index={index} index={index}
/> />
<ExpressionStatusIndicator
<AlertConditionIndicator
onSetCondition={() => onSetCondition(query.refId)}
enabled={isAlertCondition}
error={error} error={error}
onSetCondition={() => onSetCondition(query.refId)}
isCondition={isAlertCondition}
/> />
{!isAlertCondition && error && (
<Badge color="red" icon="exclamation-circle" text="Error" tooltip={error.message} />
)}
</Stack> </Stack>
); );
} }

View File

@@ -25,7 +25,7 @@ import { NeedHelpInfo } from '../NeedHelpInfo';
import { QueryEditor } from '../QueryEditor'; import { QueryEditor } from '../QueryEditor';
import { RecordingRuleEditor } from '../RecordingRuleEditor'; import { RecordingRuleEditor } from '../RecordingRuleEditor';
import { RuleEditorSection } from '../RuleEditorSection'; import { RuleEditorSection } from '../RuleEditorSection';
import { errorFromSeries, findRenamedDataQueryReferences, refIdExists } from '../util'; import { errorFromCurrentCondition, errorFromPreviewData, findRenamedDataQueryReferences, refIdExists } from '../util';
import { CloudDataSourceSelector } from './CloudDataSourceSelector'; import { CloudDataSourceSelector } from './CloudDataSourceSelector';
import { SmartAlertTypeDetector } from './SmartAlertTypeDetector'; import { SmartAlertTypeDetector } from './SmartAlertTypeDetector';
@@ -107,11 +107,13 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
useEffect(() => { useEffect(() => {
const currentCondition = getValues('condition'); const currentCondition = getValues('condition');
if (!currentCondition || RuleFormType.cloudRecording) { if (!currentCondition || !queryPreviewData[currentCondition]) {
return; return;
} }
const error = errorFromSeries(queryPreviewData[currentCondition]?.series || []); const error =
errorFromPreviewData(queryPreviewData[currentCondition]) ??
errorFromCurrentCondition(queryPreviewData[currentCondition]);
onDataChange(error?.message || ''); onDataChange(error?.message || '');
}, [queryPreviewData, getValues, onDataChange]); }, [queryPreviewData, getValues, onDataChange]);

View File

@@ -95,12 +95,13 @@ export function checkForPathSeparator(value: string): ValidateResult {
return true; return true;
} }
export function errorFromSeries(series: DataFrame[]): Error | undefined { // this function assumes we've already checked if the data passed in to the function is of the alert condition
if (series.length === 0) { export function errorFromCurrentCondition(data: PanelData): Error | undefined {
if (data.series.length === 0) {
return; return;
} }
const isTimeSeriesResults = isTimeSeriesFrames(series); const isTimeSeriesResults = isTimeSeriesFrames(data.series);
let error; let error;
if (isTimeSeriesResults) { if (isTimeSeriesResults) {
@@ -116,8 +117,7 @@ export function errorFromPreviewData(data: PanelData): Error | undefined {
return new Error(data.errors[0].message); return new Error(data.errors[0].message);
} }
// if none, return errors from series return;
return errorFromSeries(data.series);
} }
export function warningFromSeries(series: DataFrame[]): Error | undefined { export function warningFromSeries(series: DataFrame[]): Error | undefined {