mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Always show expression warnings and errors (#74839)
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) => {
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user