-
+
{showStep2 && (
<>
{type === RuleFormType.grafana ?
:
}
diff --git a/public/app/features/alerting/unified/components/rule-editor/ConditionField.test.tsx b/public/app/features/alerting/unified/components/rule-editor/ConditionField.test.tsx
deleted file mode 100644
index 440d155f385..00000000000
--- a/public/app/features/alerting/unified/components/rule-editor/ConditionField.test.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import React, { FC } from 'react';
-import { FormProvider, useForm, UseFormProps } from 'react-hook-form';
-
-import { ExpressionDatasourceUID } from 'app/features/expressions/ExpressionDatasource';
-
-import { RuleFormValues } from '../../types/rule-form';
-
-import { ConditionField } from './ConditionField';
-
-const FormProviderWrapper: FC
= ({ children, ...props }) => {
- const methods = useForm({ ...props });
- return {children};
-};
-
-describe('ConditionField', () => {
- it('should render the correct condition when editing existing rule', () => {
- const existingRule = {
- name: 'ConditionsTest',
- condition: 'B',
- queries: [
- { refId: 'A' },
- { refId: 'B', datasourceUid: ExpressionDatasourceUID },
- { refId: 'C', datasourceUid: ExpressionDatasourceUID },
- ],
- } as RuleFormValues;
-
- const form = (
-
-
-
- );
-
- render(form);
- expect(screen.getByLabelText(/^A/)).not.toBeChecked();
- expect(screen.getByLabelText(/^B/)).toBeChecked();
- expect(screen.getByLabelText(/^C/)).not.toBeChecked();
- });
-});
diff --git a/public/app/features/alerting/unified/components/rule-editor/ConditionField.tsx b/public/app/features/alerting/unified/components/rule-editor/ConditionField.tsx
deleted file mode 100644
index a07c9ba8285..00000000000
--- a/public/app/features/alerting/unified/components/rule-editor/ConditionField.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-import { css } from '@emotion/css';
-import { last } from 'lodash';
-import React, { FC, useEffect, useMemo } from 'react';
-import { useFormContext } from 'react-hook-form';
-
-import { GrafanaTheme2, SelectableValue } from '@grafana/data';
-import { Alert, Card, Field, InputControl, RadioButtonList, useStyles2 } from '@grafana/ui';
-import { ExpressionDatasourceUID } from 'app/features/expressions/ExpressionDatasource';
-
-import { RuleFormValues } from '../../types/rule-form';
-
-interface Props {
- existing?: boolean;
-}
-
-export const ConditionField: FC = ({ existing = false }) => {
- const {
- watch,
- setValue,
- formState: { errors },
- } = useFormContext();
-
- const queries = watch('queries');
- const condition = watch('condition');
-
- const expressions = useMemo(() => {
- return queries.filter((query) => query.datasourceUid === ExpressionDatasourceUID);
- }, [queries]);
-
- const options = useMemo(
- () =>
- queries
- .filter((q) => !!q.refId)
- .map>((q) => ({
- value: q.refId,
- label: `${q.refId} - ${expressions.includes(q) ? 'expression' : 'query'}`,
- })),
- [queries, expressions]
- );
-
- // automatically use the last expression when new expressions have been added
- useEffect(() => {
- const lastExpression = last(expressions);
- if (lastExpression && !existing) {
- setValue('condition', lastExpression.refId, { shouldValidate: true });
- }
- }, [expressions, setValue, existing]);
-
- // reset condition if option no longer exists or if it is unset, but there are options available
- useEffect(() => {
- const lastExpression = last(expressions);
- const conditionExists = options.find(({ value }) => value === condition);
-
- if (condition && !conditionExists) {
- setValue('condition', lastExpression?.refId ?? null);
- } else if (!condition && lastExpression) {
- setValue('condition', lastExpression.refId, { shouldValidate: true });
- }
- }, [condition, expressions, options, setValue]);
-
- const styles = useStyles2(getStyles);
-
- return options.length ? (
-
- Set alert condition
- Select one of your queries or expressions set above that contains your alert condition.
-
-
- (
-
- )}
- rules={{
- required: {
- value: true,
- message: 'Please select the condition to alert on',
- },
- }}
- />
-
-
-
- ) : (
-
- Create at least one query or expression to be alerted on
-
- );
-};
-
-const getStyles = (theme: GrafanaTheme2) => ({
- container: css`
- max-width: ${theme.breakpoints.values.sm}px;
- `,
-});
diff --git a/public/app/features/alerting/unified/components/rule-editor/ExpressionsEditor.tsx b/public/app/features/alerting/unified/components/rule-editor/ExpressionsEditor.tsx
new file mode 100644
index 00000000000..a1ecc460869
--- /dev/null
+++ b/public/app/features/alerting/unified/components/rule-editor/ExpressionsEditor.tsx
@@ -0,0 +1,68 @@
+import React, { FC, useMemo } from 'react';
+
+import { PanelData } from '@grafana/data';
+import { Stack } from '@grafana/ui';
+import { isExpressionQuery } from 'app/features/expressions/guards';
+import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types';
+import { AlertQuery } from 'app/types/unified-alerting-dto';
+
+import { Expression } from '../expressions/Expression';
+
+import { errorFromSeries, warningFromSeries } from './util';
+
+interface Props {
+ condition: string | null;
+ onSetCondition: (refId: string) => void;
+ panelData: Record;
+ queries: AlertQuery[];
+ onRemoveExpression: (refId: string) => void;
+ onUpdateRefId: (oldRefId: string, newRefId: string) => void;
+ onUpdateExpressionType: (refId: string, type: ExpressionQueryType) => void;
+ onUpdateQueryExpression: (query: ExpressionQuery) => void;
+}
+
+export const ExpressionsEditor: FC = ({
+ condition,
+ onSetCondition,
+ queries,
+ panelData,
+ onUpdateRefId,
+ onRemoveExpression,
+ onUpdateExpressionType,
+ onUpdateQueryExpression,
+}) => {
+ const expressionQueries = useMemo(() => {
+ return queries.reduce((acc: ExpressionQuery[], query) => {
+ return isExpressionQuery(query.model) ? acc.concat(query.model) : acc;
+ }, []);
+ }, [queries]);
+
+ return (
+
+ {expressionQueries.map((query) => {
+ const data = panelData[query.refId];
+
+ const isAlertCondition = condition === query.refId;
+ const error = isAlertCondition && data ? errorFromSeries(data.series) : undefined;
+ const warning = isAlertCondition && data ? warningFromSeries(data.series) : undefined;
+
+ return (
+
+ );
+ })}
+
+ );
+};
diff --git a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx
index c8ff4d087dd..cf59072f1aa 100644
--- a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx
@@ -16,7 +16,6 @@ import { CollapseToggle } from '../CollapseToggle';
import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning';
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
-import { PreviewRule } from './PreviewRule';
import { RuleEditorSection } from './RuleEditorSection';
const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds
@@ -161,7 +160,6 @@ export const GrafanaEvaluationBehavior: FC = () => {
>
)}
-
);
};
diff --git a/public/app/features/alerting/unified/components/rule-editor/QueryEditor.test.tsx b/public/app/features/alerting/unified/components/rule-editor/QueryEditor.test.tsx
deleted file mode 100644
index 7036b1ed10a..00000000000
--- a/public/app/features/alerting/unified/components/rule-editor/QueryEditor.test.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-import { render } from '@testing-library/react';
-import React from 'react';
-import { byLabelText, byTestId, byText } from 'testing-library-selector';
-
-import { getDefaultRelativeTimeRange } from '@grafana/data';
-import { selectors } from '@grafana/e2e-selectors';
-import { setDataSourceSrv } from '@grafana/runtime';
-
-import { MockDataSourceApi } from '../../../../../../test/mocks/datasource_srv';
-import { ExpressionDatasourceUID, instanceSettings } from '../../../../expressions/ExpressionDatasource';
-import { mockDataSource, MockDataSourceSrv } from '../../mocks';
-import { getDefaultQueries } from '../../utils/rule-form';
-
-import { QueryEditor } from './QueryEditor';
-
-const ui = {
- queryNames: byTestId('query-name-div'),
- dataSourcePicker: byLabelText(selectors.components.DataSourcePicker.container),
- noDataSourcesWarning: byText('You appear to have no compatible data sources'),
-};
-
-const onChangeMock = jest.fn();
-describe('Query Editor', () => {
- it('should maintain the original query time range when duplicating it', () => {
- const query = {
- refId: 'A',
- queryType: '',
- datasourceUid: '',
- model: { refId: 'A', hide: false },
- relativeTimeRange: { from: 100, to: 0 },
- };
- const queryEditor = new QueryEditor({
- onChange: onChangeMock,
- value: [query],
- });
-
- queryEditor.onDuplicateQuery(query);
-
- expect(onChangeMock).toHaveBeenCalledWith([
- query,
- { ...query, ...{ refId: 'B', model: { refId: 'B', hide: false } } },
- ]);
- });
-
- it('should use the default query time range if none is set when duplicating a query', () => {
- const query = {
- refId: 'A',
- queryType: '',
- datasourceUid: '',
- model: { refId: 'A', hide: false },
- };
- const queryEditor = new QueryEditor({
- onChange: onChangeMock,
- value: [query],
- });
-
- queryEditor.onDuplicateQuery(query);
-
- const defaultRange = getDefaultRelativeTimeRange();
-
- expect(onChangeMock).toHaveBeenCalledWith([
- query,
- { ...query, ...{ refId: 'B', relativeTimeRange: defaultRange, model: { refId: 'B', hide: false } } },
- ]);
- });
-
- it('should select first data source supporting alerting when there is no default data source', async () => {
- const dsServer = new MockDataSourceSrv({
- influx: mockDataSource({ name: 'influx' }, { alerting: true }),
- postgres: mockDataSource({ name: 'postgres' }, { alerting: true }),
- [ExpressionDatasourceUID]: instanceSettings,
- });
- dsServer.get = () => Promise.resolve(new MockDataSourceApi());
-
- setDataSourceSrv(dsServer);
-
- const defaultQueries = getDefaultQueries();
-
- render( null} value={defaultQueries} />);
-
- const queryRef = await ui.queryNames.findAll();
- const select = await ui.dataSourcePicker.find();
-
- expect(queryRef).toHaveLength(2);
- expect(queryRef[0]).toHaveTextContent('A');
- expect(queryRef[1]).toHaveTextContent('B');
- expect(select).toHaveTextContent('influx'); // Alphabetical order
- expect(ui.noDataSourcesWarning.query()).not.toBeInTheDocument();
- });
-
- it('should select the default data source when specified', async () => {
- const dsServer = new MockDataSourceSrv({
- influx: mockDataSource({ name: 'influx' }, { alerting: true }),
- postgres: mockDataSource({ name: 'postgres', isDefault: true }, { alerting: true }),
- [ExpressionDatasourceUID]: instanceSettings,
- });
- dsServer.get = () => Promise.resolve(new MockDataSourceApi());
-
- setDataSourceSrv(dsServer);
-
- const defaultQueries = getDefaultQueries();
-
- render( null} value={defaultQueries} />);
-
- const queryRef = await ui.queryNames.findAll();
- const select = await ui.dataSourcePicker.find();
-
- expect(queryRef).toHaveLength(2);
- expect(select).toHaveTextContent('postgres'); // Default data source
- });
-});
diff --git a/public/app/features/alerting/unified/components/rule-editor/QueryEditor.tsx b/public/app/features/alerting/unified/components/rule-editor/QueryEditor.tsx
index 243309d58b9..d41a10472b4 100644
--- a/public/app/features/alerting/unified/components/rule-editor/QueryEditor.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/QueryEditor.tsx
@@ -1,227 +1,52 @@
import { css } from '@emotion/css';
-import React, { PureComponent } from 'react';
+import React, { FC } from 'react';
-import {
- DataQuery,
- getDefaultRelativeTimeRange,
- GrafanaTheme2,
- LoadingState,
- PanelData,
- RelativeTimeRange,
-} from '@grafana/data';
-import { selectors } from '@grafana/e2e-selectors';
-import { config } from '@grafana/runtime';
-import { Button, HorizontalGroup, stylesFactory, Tooltip } from '@grafana/ui';
-import { getNextRefIdChar } from 'app/core/utils/query';
-import {
- dataSource as expressionDatasource,
- ExpressionDatasourceUID,
-} from 'app/features/expressions/ExpressionDatasource';
-import { isExpressionQuery } from 'app/features/expressions/guards';
-import { ExpressionQueryType } from 'app/features/expressions/types';
-import { defaultCondition } from 'app/features/expressions/utils/expressionTypes';
+import { GrafanaTheme2, PanelData } from '@grafana/data';
+import { useStyles2 } from '@grafana/ui';
import { AlertQuery } from 'app/types/unified-alerting-dto';
-import { AlertingQueryRunner } from '../../state/AlertingQueryRunner';
-import { getDefaultOrFirstCompatibleDataSource } from '../../utils/datasource';
-
import { QueryRows } from './QueryRows';
interface Props {
- value?: AlertQuery[];
- onChange: (queries: AlertQuery[]) => void;
+ panelData: Record;
+ queries: AlertQuery[];
+ onRunQueries: () => void;
+ onChangeQueries: (queries: AlertQuery[]) => void;
+ onDuplicateQuery: (query: AlertQuery) => void;
+ condition: string | null;
+ onSetCondition: (refId: string) => void;
}
-interface State {
- panelDataByRefId: Record;
-}
+export const QueryEditor: FC = ({
+ queries,
+ panelData,
+ onRunQueries,
+ onChangeQueries,
+ onDuplicateQuery,
+ condition,
+ onSetCondition,
+}) => {
+ const styles = useStyles2(getStyles);
-export class QueryEditor extends PureComponent {
- private runner: AlertingQueryRunner;
- private queries: AlertQuery[];
-
- constructor(props: Props) {
- super(props);
- this.state = { panelDataByRefId: {} };
- this.runner = new AlertingQueryRunner();
- this.queries = props.value ?? [];
- }
-
- componentDidMount() {
- this.runner.get().subscribe((data) => {
- this.setState({ panelDataByRefId: data });
- });
- }
-
- componentWillUnmount() {
- this.runner.destroy();
- }
-
- onRunQueries = () => {
- const { queries } = this;
- this.runner.run(queries);
- };
-
- onCancelQueries = () => {
- this.runner.cancel();
- };
-
- onChangeQueries = (queries: AlertQuery[]) => {
- this.queries = queries;
- this.props.onChange(queries);
- };
-
- onDuplicateQuery = (query: AlertQuery) => {
- const { queries } = this;
- this.onChangeQueries(addQuery(queries, query));
- };
-
- onNewAlertingQuery = () => {
- const { queries } = this;
- const datasource = getDefaultOrFirstCompatibleDataSource();
-
- if (!datasource) {
- return;
- }
-
- this.onChangeQueries(
- addQuery(queries, {
- datasourceUid: datasource.uid,
- model: {
- refId: '',
- datasource: {
- type: datasource.type,
- uid: datasource.uid,
- },
- },
- })
- );
- };
-
- onNewExpressionQuery = () => {
- const { queries } = this;
-
- const lastQuery = queries.at(-1);
- const defaultParams = lastQuery ? [lastQuery.refId] : [];
-
- this.onChangeQueries(
- addQuery(queries, {
- datasourceUid: ExpressionDatasourceUID,
- model: expressionDatasource.newQuery({
- type: ExpressionQueryType.classic,
- conditions: [{ ...defaultCondition, query: { params: defaultParams } }],
- expression: lastQuery?.refId,
- }),
- })
- );
- };
-
- isRunning() {
- const data = Object.values(this.state.panelDataByRefId).find((d) => Boolean(d));
- return data?.state === LoadingState.Loading;
- }
-
- renderRunQueryButton() {
- const isRunning = this.isRunning();
-
- if (isRunning) {
- return (
-
- );
- }
-
- return (
-
- );
- }
-
- render() {
- const { value = [] } = this.props;
- const { panelDataByRefId } = this.state;
- const styles = getStyles(config.theme2);
-
- const noCompatibleDataSources = getDefaultOrFirstCompatibleDataSource() === undefined;
-
- return (
-
-
-
-
-
-
- {config.expressionsEnabled && (
-
- )}
- {this.renderRunQueryButton()}
-
-
- );
- }
-}
-
-const addQuery = (
- queries: AlertQuery[],
- queryToAdd: Pick
-): AlertQuery[] => {
- const refId = getNextRefIdChar(queries);
-
- const query: AlertQuery = {
- ...queryToAdd,
- refId,
- queryType: '',
- model: {
- ...queryToAdd.model,
- hide: false,
- refId,
- },
- relativeTimeRange: queryToAdd.relativeTimeRange || defaultTimeRange(queryToAdd.model),
- };
-
- return [...queries, query];
+ return (
+
+
+
+ );
};
-const defaultTimeRange = (model: DataQuery): RelativeTimeRange | undefined => {
- if (isExpressionQuery(model)) {
- return;
- }
-
- return getDefaultRelativeTimeRange();
-};
-
-const getStyles = stylesFactory((theme: GrafanaTheme2) => {
- return {
- container: css`
- background-color: ${theme.colors.background.primary};
- height: 100%;
- max-width: ${theme.breakpoints.values.xxl}px;
- `,
- runWrapper: css`
- margin-top: ${theme.spacing(1)};
- `,
- editorWrapper: css`
- border: 1px solid ${theme.colors.border.medium};
- border-radius: ${theme.shape.borderRadius()};
- `,
- };
+const getStyles = (theme: GrafanaTheme2) => ({
+ container: css`
+ background-color: ${theme.colors.background.primary};
+ height: 100%;
+ max-width: ${theme.breakpoints.values.xxl}px;
+ `,
});
diff --git a/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx b/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx
index 77b1feb17cf..d0b424efcb4 100644
--- a/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx
@@ -19,36 +19,29 @@ import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto';
import { EmptyQueryWrapper, QueryWrapper } from './QueryWrapper';
-import { queriesWithUpdatedReferences } from './util';
+import { errorFromSeries } from './util';
interface Props {
// The query configuration
queries: AlertQuery[];
data: Record;
+ onRunQueries: () => void;
// Query editing
onQueriesChange: (queries: AlertQuery[]) => void;
onDuplicateQuery: (query: AlertQuery) => void;
- onRunQueries: () => void;
+ condition: string | null;
+ onSetCondition: (refId: string) => void;
}
-interface State {
- dataPerQuery: Record;
-}
-
-export class QueryRows extends PureComponent {
+export class QueryRows extends PureComponent {
constructor(props: Props) {
super(props);
-
- this.state = { dataPerQuery: {} };
}
onRemoveQuery = (query: DataQuery) => {
- this.props.onQueriesChange(
- this.props.queries.filter((item) => {
- return item.model.refId !== query.refId;
- })
- );
+ const { queries, onQueriesChange } = this.props;
+ onQueriesChange(queries.filter((q) => q.refId !== query.refId));
};
onChangeTimeRange = (timeRange: RelativeTimeRange, index: number) => {
@@ -119,12 +112,8 @@ export class QueryRows extends PureComponent {
onChangeQuery = (query: DataQuery, index: number) => {
const { queries, onQueriesChange } = this.props;
- // find what queries still have a reference to the old name
- const previousRefId = queries[index].refId;
- const newRefId = query.refId;
-
onQueriesChange(
- queriesWithUpdatedReferences(queries, previousRefId, newRefId).map((item, itemIndex) => {
+ queries.map((item, itemIndex) => {
if (itemIndex !== index) {
return item;
}
@@ -162,13 +151,6 @@ export class QueryRows extends PureComponent {
onQueriesChange(update);
};
- onDuplicateQuery = (query: DataQuery, source: AlertQuery): void => {
- this.props.onDuplicateQuery({
- ...source,
- model: query,
- });
- };
-
getDataSourceSettings = (query: AlertQuery): DataSourceInstanceSettings | undefined => {
return getDataSourceSrv().getInstanceSettings(query.datasourceUid);
};
@@ -218,7 +200,7 @@ export class QueryRows extends PureComponent {
};
render() {
- const { onDuplicateQuery, onRunQueries, queries } = this.props;
+ const { queries } = this.props;
const thresholdByRefId = this.getThresholdsForQueries(queries);
return (
@@ -234,6 +216,9 @@ export class QueryRows extends PureComponent {
};
const dsSettings = this.getDataSourceSettings(query);
+ const isAlertCondition = this.props.condition === query.refId;
+ const error = isAlertCondition ? errorFromSeries(data.series) : undefined;
+
if (!dsSettings) {
return (
{
key={query.refId}
dsSettings={dsSettings}
data={data}
+ error={error}
query={query}
onChangeQuery={this.onChangeQuery}
onRemoveQuery={this.onRemoveQuery}
queries={queries}
onChangeDataSource={this.onChangeDataSource}
- onDuplicateQuery={onDuplicateQuery}
- onRunQueries={onRunQueries}
+ onDuplicateQuery={this.props.onDuplicateQuery}
onChangeTimeRange={this.onChangeTimeRange}
thresholds={thresholdByRefId[query.refId]}
onChangeThreshold={this.onChangeThreshold}
+ onRunQueries={this.props.onRunQueries}
+ condition={this.props.condition}
+ onSetCondition={this.props.onSetCondition}
/>
);
})}
diff --git a/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx b/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx
index 0538ea7a3bd..0fa08b0bbad 100644
--- a/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx
@@ -13,18 +13,20 @@ import {
RelativeTimeRange,
ThresholdsConfig,
} from '@grafana/data';
-import { RelativeTimeRangePicker, useStyles2, Tooltip, Icon } from '@grafana/ui';
+import { RelativeTimeRangePicker, useStyles2, Tooltip, Icon, Stack } from '@grafana/ui';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { QueryEditorRow } from 'app/features/query/components/QueryEditorRow';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { TABLE, TIMESERIES } from '../../utils/constants';
import { SupportedPanelPlugins } from '../PanelPluginsButtonGroup';
+import { AlertConditionIndicator } from '../expressions/AlertConditionIndicator';
import { VizWrapper } from './VizWrapper';
interface Props {
data: PanelData;
+ error?: Error;
query: AlertQuery;
queries: AlertQuery[];
dsSettings: DataSourceInstanceSettings;
@@ -37,10 +39,13 @@ interface Props {
index: number;
thresholds: ThresholdsConfig;
onChangeThreshold: (thresholds: ThresholdsConfig, index: number) => void;
+ condition: string | null;
+ onSetCondition: (refId: string) => void;
}
export const QueryWrapper: FC = ({
data,
+ error,
dsSettings,
index,
onChangeDataSource,
@@ -53,6 +58,8 @@ export const QueryWrapper: FC = ({
queries,
thresholds,
onChangeThreshold,
+ condition,
+ onSetCondition,
}) => {
const styles = useStyles2(getStyles);
const isExpression = isExpressionQuery(query.model);
@@ -84,12 +91,13 @@ export const QueryWrapper: FC = ({
);
}
- function HeaderExtras({ query, index }: { query: AlertQuery; index: number }) {
+ // TODO add a warning label here too when the data looks like time series data and is used as an alert condition
+ function HeaderExtras({ query, error, index }: { query: AlertQuery; error?: Error; index: number }) {
if (isExpressionQuery(query.model)) {
return null;
} else {
return (
- <>
+
{onChangeTimeRange && (
= ({
onChange={(range) => onChangeTimeRange(range, index)}
/>
)}
- >
+ onSetCondition(query.refId)}
+ enabled={condition === query.refId}
+ error={error}
+ />
+
);
}
}
@@ -118,7 +131,7 @@ export const QueryWrapper: FC = ({
onAddQuery={() => onDuplicateQuery(cloneDeep(query))}
onRunQuery={onRunQueries}
queries={queries}
- renderHeaderExtras={() => }
+ renderHeaderExtras={() => }
app={CoreApp.UnifiedAlerting}
visualization={
data.state !== LoadingState.NotStarted ? (
@@ -152,7 +165,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
dsTooltip: css`
display: flex;
align-items: center;
- margin-right: ${theme.spacing(2)};
&:hover {
opacity: 0.85;
cursor: pointer;
diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/Query.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/Query.tsx
deleted file mode 100644
index 5b1ebd874df..00000000000
--- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/Query.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import React, { FC } from 'react';
-import { useFormContext } from 'react-hook-form';
-
-import { Field, InputControl } from '@grafana/ui';
-
-import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
-import { ExpressionEditor } from '../ExpressionEditor';
-import { QueryEditor } from '../QueryEditor';
-
-export const Query: FC = () => {
- const {
- control,
- watch,
- formState: { errors },
- } = useFormContext();
-
- const [type, dataSourceName] = watch(['type', 'dataSourceName']);
-
- const isGrafanaManagedType = type === RuleFormType.grafana;
- const isCloudAlertRuleType = type === RuleFormType.cloudAlerting;
- const isRecordingRuleType = type === RuleFormType.cloudRecording;
-
- const showCloudExpressionEditor = (isRecordingRuleType || isCloudAlertRuleType) && dataSourceName;
-
- return (
-
- {/* This is the PromQL Editor for Cloud rules and recording rules */}
- {showCloudExpressionEditor && (
-
- {
- return ;
- }}
- control={control}
- rules={{
- required: { value: true, message: 'A valid expression is required' },
- }}
- />
-
- )}
-
- {/* This is the editor for Grafana managed rules */}
- {isGrafanaManagedType && (
-
- }
- control={control}
- rules={{
- validate: (queries) => Array.isArray(queries) && !!queries.length,
- }}
- />
-
- )}
-
- );
-};
diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndAlertConditionStep.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndAlertConditionStep.tsx
deleted file mode 100644
index c4031f8762d..00000000000
--- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndAlertConditionStep.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import React, { FC } from 'react';
-import { useFormContext } from 'react-hook-form';
-
-import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
-import { ConditionField } from '../ConditionField';
-import { RuleEditorSection } from '../RuleEditorSection';
-
-import { AlertType } from './AlertType';
-import { Query } from './Query';
-
-interface Props {
- editingExistingRule: boolean;
-}
-
-export const QueryAndAlertConditionStep: FC = ({ editingExistingRule }) => {
- const { watch } = useFormContext();
-
- const type = watch('type');
- const isGrafanaManagedType = type === RuleFormType.grafana;
-
- return (
-
-
- {type && }
- {isGrafanaManagedType && }
-
- );
-};
diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx
new file mode 100644
index 00000000000..3e99c6f88fd
--- /dev/null
+++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx
@@ -0,0 +1,253 @@
+import React, { FC, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
+import { useFormContext } from 'react-hook-form';
+
+import { LoadingState, PanelData } from '@grafana/data';
+import { selectors } from '@grafana/e2e-selectors';
+import { config } from '@grafana/runtime';
+import { Alert, Button, Field, InputControl, Stack, Tooltip } from '@grafana/ui';
+import { isExpressionQuery } from 'app/features/expressions/guards';
+import { AlertQuery } from 'app/types/unified-alerting-dto';
+
+import { AlertingQueryRunner } from '../../../state/AlertingQueryRunner';
+import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
+import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource';
+import { ExpressionEditor } from '../ExpressionEditor';
+import { ExpressionsEditor } from '../ExpressionsEditor';
+import { QueryEditor } from '../QueryEditor';
+import { RuleEditorSection } from '../RuleEditorSection';
+import { refIdExists } from '../util';
+
+import { AlertType } from './AlertType';
+import {
+ duplicateQuery,
+ addNewDataQuery,
+ addNewExpression,
+ queriesAndExpressionsReducer,
+ removeExpression,
+ rewireExpressions,
+ setDataQueries,
+ updateExpression,
+ updateExpressionRefId,
+ updateExpressionType,
+} from './reducer';
+
+interface Props {
+ editingExistingRule: boolean;
+}
+
+export const QueryAndExpressionsStep: FC = ({ editingExistingRule }) => {
+ const runner = useRef(new AlertingQueryRunner());
+ const {
+ setValue,
+ getValues,
+ watch,
+ formState: { errors },
+ control,
+ } = useFormContext();
+ const [panelData, setPanelData] = useState>({});
+
+ const initialState = {
+ queries: getValues('queries'),
+ panelData: {},
+ };
+ const [{ queries }, dispatch] = useReducer(queriesAndExpressionsReducer, initialState);
+
+ const [type, condition, dataSourceName] = watch(['type', 'condition', 'dataSourceName']);
+
+ const isGrafanaManagedType = type === RuleFormType.grafana;
+ const isCloudAlertRuleType = type === RuleFormType.cloudAlerting;
+ const isRecordingRuleType = type === RuleFormType.cloudRecording;
+
+ const showCloudExpressionEditor = (isRecordingRuleType || isCloudAlertRuleType) && dataSourceName;
+
+ const cancelQueries = useCallback(() => {
+ runner.current.cancel();
+ }, []);
+
+ const runQueries = useCallback(() => {
+ runner.current.run(queries);
+ }, [queries]);
+
+ // whenever we update the queries we have to update the form too
+ useEffect(() => {
+ setValue('queries', queries, { shouldValidate: false });
+ }, [queries, runQueries, setValue]);
+
+ // set up the AlertQueryRunner
+ useEffect(() => {
+ const currentRunner = runner.current;
+
+ runner.current.get().subscribe((data) => {
+ setPanelData(data);
+ });
+
+ return () => currentRunner.destroy();
+ }, []);
+
+ const noCompatibleDataSources = getDefaultOrFirstCompatibleDataSource() === undefined;
+
+ const isDataLoading = useMemo(() => {
+ return Object.values(panelData).some((d) => d.state === LoadingState.Loading);
+ }, [panelData]);
+
+ // data queries only
+ const dataQueries = useMemo(() => {
+ return queries.filter((query) => !isExpressionQuery(query.model));
+ }, [queries]);
+
+ const emptyQueries = queries.length === 0;
+
+ const onUpdateRefId = useCallback(
+ (oldRefId: string, newRefId: string) => {
+ const newRefIdExists = refIdExists(queries, newRefId);
+ // TODO we should set an error and explain what went wrong instead of just refusing to update
+ if (newRefIdExists) {
+ return;
+ }
+
+ dispatch(updateExpressionRefId({ oldRefId, newRefId }));
+
+ // update condition too if refId was updated
+ if (condition === oldRefId) {
+ setValue('condition', newRefId);
+ }
+ },
+ [condition, queries, setValue]
+ );
+
+ const onChangeQueries = useCallback(
+ (updatedQueries: AlertQuery[]) => {
+ dispatch(setDataQueries(updatedQueries));
+
+ // check if we need to rewire expressions
+ updatedQueries.forEach((query, index) => {
+ const oldRefId = queries[index].refId;
+ const newRefId = query.refId;
+
+ if (oldRefId !== newRefId) {
+ dispatch(rewireExpressions({ oldRefId, newRefId }));
+ }
+ });
+ },
+ [queries]
+ );
+
+ const onDuplicateQuery = useCallback((query: AlertQuery) => {
+ dispatch(duplicateQuery(query));
+ }, []);
+
+ // update the condition if it's been removed
+ useEffect(() => {
+ if (!refIdExists(queries, condition)) {
+ const lastRefId = queries.at(-1)?.refId ?? null;
+ setValue('condition', lastRefId);
+ }
+ }, [condition, queries, setValue]);
+
+ return (
+
+
+
+ {/* This is the PromQL Editor for Cloud rules and recording rules */}
+ {showCloudExpressionEditor && (
+
+ {
+ return ;
+ }}
+ control={control}
+ rules={{
+ required: { value: true, message: 'A valid expression is required' },
+ }}
+ />
+
+ )}
+
+ {/* This is the editor for Grafana managed rules */}
+ {isGrafanaManagedType && (
+
+ {/* Data Queries */}
+ {
+ setValue('condition', refId);
+ }}
+ />
+ {/* Expression Queries */}
+ {
+ setValue('condition', refId);
+ }}
+ onRemoveExpression={(refId) => {
+ dispatch(removeExpression(refId));
+ }}
+ onUpdateRefId={onUpdateRefId}
+ onUpdateExpressionType={(refId, type) => {
+ dispatch(updateExpressionType({ refId, type }));
+ }}
+ onUpdateQueryExpression={(model) => {
+ dispatch(updateExpression(model));
+ }}
+ />
+ {/* action buttons */}
+
+
+
+
+
+ {config.expressionsEnabled && (
+
+ )}
+
+ {isDataLoading && (
+
+ )}
+ {!isDataLoading && (
+
+ )}
+
+
+ {/* No Queries */}
+ {emptyQueries && (
+
+ Create at least one query or expression to be alerted on
+
+ )}
+
+ )}
+
+ );
+};
diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/reducer.test.tsx.snap b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/reducer.test.tsx.snap
new file mode 100644
index 00000000000..b8678183f40
--- /dev/null
+++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/reducer.test.tsx.snap
@@ -0,0 +1,382 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Query and expressions reducer should add a new expression 1`] = `
+Object {
+ "queries": Array [
+ Object {
+ "datasourceUid": "abc123",
+ "model": Object {
+ "refId": "A",
+ },
+ "queryType": "query",
+ "refId": "A",
+ },
+ Object {
+ "datasourceUid": "-100",
+ "model": Object {
+ "conditions": Array [
+ Object {
+ "evaluator": Object {
+ "params": Array [
+ 0,
+ 0,
+ ],
+ "type": "gt",
+ },
+ "operator": Object {
+ "type": "and",
+ },
+ "query": Object {
+ "params": Array [],
+ },
+ "reducer": Object {
+ "params": Array [],
+ "type": "avg",
+ },
+ "type": "query",
+ },
+ ],
+ "datasource": Object {
+ "name": "Expression",
+ "type": "__expr__",
+ "uid": "__expr__",
+ },
+ "expression": "",
+ "hide": false,
+ "refId": "B",
+ "type": "math",
+ },
+ "queryType": "",
+ "refId": "B",
+ "relativeTimeRange": undefined,
+ },
+ ],
+}
+`;
+
+exports[`Query and expressions reducer should add query 1`] = `
+Object {
+ "queries": Array [
+ Object {
+ "datasourceUid": "abc123",
+ "model": Object {
+ "refId": "A",
+ },
+ "queryType": "query",
+ "refId": "A",
+ },
+ Object {
+ "datasourceUid": "c8eceabb-0275-4108-8f03-8f74faf4bf6d",
+ "model": Object {
+ "datasource": Object {
+ "type": "prometheus",
+ "uid": "c8eceabb-0275-4108-8f03-8f74faf4bf6d",
+ },
+ "hide": false,
+ "refId": "B",
+ },
+ "queryType": "",
+ "refId": "B",
+ "relativeTimeRange": Object {
+ "from": 600,
+ "to": 0,
+ },
+ },
+ ],
+}
+`;
+
+exports[`Query and expressions reducer should duplicate query 1`] = `
+Object {
+ "queries": Array [
+ Object {
+ "datasourceUid": "abc123",
+ "model": Object {
+ "refId": "A",
+ },
+ "queryType": "query",
+ "refId": "A",
+ },
+ Object {
+ "datasourceUid": "abc123",
+ "model": Object {
+ "hide": false,
+ "refId": "B",
+ },
+ "queryType": "",
+ "refId": "B",
+ "relativeTimeRange": Object {
+ "from": 600,
+ "to": 0,
+ },
+ },
+ ],
+}
+`;
+
+exports[`Query and expressions reducer should remove an expression or alert query 1`] = `
+Object {
+ "queries": Array [
+ Object {
+ "datasourceUid": "abc123",
+ "model": Object {
+ "refId": "A",
+ },
+ "queryType": "query",
+ "refId": "A",
+ },
+ ],
+}
+`;
+
+exports[`Query and expressions reducer should rewire expressions 1`] = `
+Object {
+ "queries": Array [
+ Object {
+ "datasourceUid": "abc123",
+ "model": Object {
+ "refId": "A",
+ },
+ "queryType": "query",
+ "refId": "A",
+ },
+ Object {
+ "datasourceUid": "-100",
+ "model": Object {
+ "conditions": Array [
+ Object {
+ "evaluator": Object {
+ "params": Array [
+ 0,
+ 0,
+ ],
+ "type": "gt",
+ },
+ "operator": Object {
+ "type": "and",
+ },
+ "query": Object {
+ "params": Array [
+ "C",
+ ],
+ },
+ "reducer": Object {
+ "params": Array [],
+ "type": "avg",
+ },
+ "type": "query",
+ },
+ ],
+ "datasource": Object {
+ "name": "Expression",
+ "type": "__expr__",
+ "uid": "__expr__",
+ },
+ "expression": "",
+ "refId": "B",
+ "type": "classic_conditions",
+ },
+ "queryType": "",
+ "refId": "B",
+ },
+ ],
+}
+`;
+
+exports[`Query and expressions reducer should set data queries 1`] = `
+Object {
+ "queries": Array [
+ Object {
+ "datasourceUid": "-100",
+ "model": Object {
+ "conditions": Array [
+ Object {
+ "evaluator": Object {
+ "params": Array [
+ 0,
+ 0,
+ ],
+ "type": "gt",
+ },
+ "operator": Object {
+ "type": "and",
+ },
+ "query": Object {
+ "params": Array [
+ "A",
+ ],
+ },
+ "reducer": Object {
+ "params": Array [],
+ "type": "avg",
+ },
+ "type": "query",
+ },
+ ],
+ "datasource": Object {
+ "name": "Expression",
+ "type": "__expr__",
+ "uid": "__expr__",
+ },
+ "expression": "",
+ "refId": "B",
+ "type": "classic_conditions",
+ },
+ "queryType": "",
+ "refId": "B",
+ },
+ ],
+}
+`;
+
+exports[`Query and expressions reducer should update an expression 1`] = `
+Object {
+ "queries": Array [
+ Object {
+ "datasourceUid": "-100",
+ "model": Object {
+ "conditions": Array [
+ Object {
+ "evaluator": Object {
+ "params": Array [
+ 0,
+ 0,
+ ],
+ "type": "gt",
+ },
+ "operator": Object {
+ "type": "and",
+ },
+ "query": Object {
+ "params": Array [
+ "A",
+ ],
+ },
+ "reducer": Object {
+ "params": Array [],
+ "type": "avg",
+ },
+ "type": "query",
+ },
+ ],
+ "datasource": Object {
+ "name": "Expression",
+ "type": "__expr__",
+ "uid": "__expr__",
+ },
+ "expression": "",
+ "refId": "B",
+ "type": "math",
+ },
+ "queryType": "",
+ "refId": "B",
+ },
+ ],
+}
+`;
+
+exports[`Query and expressions reducer should update an expression refId and rewire expressions 1`] = `
+Object {
+ "queries": Array [
+ Object {
+ "datasourceUid": "abc123",
+ "model": Object {
+ "refId": "C",
+ },
+ "queryType": "query",
+ "refId": "C",
+ },
+ Object {
+ "datasourceUid": "-100",
+ "model": Object {
+ "conditions": Array [
+ Object {
+ "evaluator": Object {
+ "params": Array [
+ 0,
+ 0,
+ ],
+ "type": "gt",
+ },
+ "operator": Object {
+ "type": "and",
+ },
+ "query": Object {
+ "params": Array [
+ "C",
+ ],
+ },
+ "reducer": Object {
+ "params": Array [],
+ "type": "avg",
+ },
+ "type": "query",
+ },
+ ],
+ "datasource": Object {
+ "name": "Expression",
+ "type": "__expr__",
+ "uid": "__expr__",
+ },
+ "expression": "",
+ "refId": "B",
+ "type": "classic_conditions",
+ },
+ "queryType": "",
+ "refId": "B",
+ },
+ ],
+}
+`;
+
+exports[`Query and expressions reducer should update expression type 1`] = `
+Object {
+ "queries": Array [
+ Object {
+ "datasourceUid": "abc123",
+ "model": Object {
+ "refId": "A",
+ },
+ "queryType": "query",
+ "refId": "A",
+ },
+ Object {
+ "datasourceUid": "-100",
+ "model": Object {
+ "conditions": Array [
+ Object {
+ "evaluator": Object {
+ "params": Array [
+ 0,
+ 0,
+ ],
+ "type": "gt",
+ },
+ "operator": Object {
+ "type": "and",
+ },
+ "query": Object {
+ "params": Array [],
+ },
+ "reducer": Object {
+ "params": Array [],
+ "type": "avg",
+ },
+ "type": "query",
+ },
+ ],
+ "datasource": Object {
+ "name": "Expression",
+ "type": "__expr__",
+ "uid": "__expr__",
+ },
+ "expression": "",
+ "refId": "B",
+ "type": "reduce",
+ },
+ "queryType": "",
+ "refId": "B",
+ },
+ ],
+}
+`;
diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.test.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.test.tsx
new file mode 100644
index 00000000000..835e8d24d41
--- /dev/null
+++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.test.tsx
@@ -0,0 +1,213 @@
+import { getDefaultRelativeTimeRange } from '@grafana/data';
+import { getDataSourceSrv } from '@grafana/runtime/src/services/__mocks__/dataSourceSrv';
+import {
+ dataSource as expressionDatasource,
+ ExpressionDatasourceUID,
+} from 'app/features/expressions/ExpressionDatasource';
+import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types';
+import { defaultCondition } from 'app/features/expressions/utils/expressionTypes';
+import { AlertQuery } from 'app/types/unified-alerting-dto';
+
+import {
+ addNewDataQuery,
+ addNewExpression,
+ duplicateQuery,
+ queriesAndExpressionsReducer,
+ QueriesAndExpressionsState,
+ removeExpression,
+ rewireExpressions,
+ setDataQueries,
+ updateExpression,
+ updateExpressionRefId,
+ updateExpressionType,
+} from './reducer';
+
+jest.mock('@grafana/runtime', () => ({
+ ...jest.requireActual('@grafana/runtime'),
+ getDataSourceSrv: getDataSourceSrv,
+}));
+
+const alertQuery: AlertQuery = {
+ refId: 'A',
+ queryType: 'query',
+ datasourceUid: 'abc123',
+ model: {
+ refId: 'A',
+ },
+};
+
+const expressionQuery: AlertQuery = {
+ datasourceUid: ExpressionDatasourceUID,
+ model: expressionDatasource.newQuery({
+ type: ExpressionQueryType.classic,
+ conditions: [{ ...defaultCondition, query: { params: ['A'] } }],
+ expression: '',
+ refId: 'B',
+ }),
+ refId: 'B',
+ queryType: '',
+};
+
+describe('Query and expressions reducer', () => {
+ it('should return initial state', () => {
+ expect(queriesAndExpressionsReducer(undefined, { type: undefined })).toEqual({
+ queries: [],
+ });
+ });
+
+ it('should duplicate query', () => {
+ const initialState: QueriesAndExpressionsState = {
+ queries: [alertQuery],
+ };
+
+ const newState = queriesAndExpressionsReducer(initialState, duplicateQuery(alertQuery));
+ const newQuery = newState.queries.at(-1);
+ expect(newState).toMatchSnapshot();
+ expect(newQuery).toHaveProperty('relativeTimeRange', getDefaultRelativeTimeRange());
+ });
+
+ it('should duplicate query and copy time range', () => {
+ const initialState: QueriesAndExpressionsState = {
+ queries: [alertQuery],
+ };
+
+ const customTimeRange = {
+ from: -200,
+ to: 800,
+ };
+
+ const query: AlertQuery = {
+ ...initialState.queries[0],
+ relativeTimeRange: customTimeRange,
+ };
+
+ const previousState: QueriesAndExpressionsState = {
+ queries: [query],
+ };
+
+ const newState = queriesAndExpressionsReducer(previousState, duplicateQuery(query));
+ const newQuery = newState.queries.at(-1);
+ expect(newQuery).toHaveProperty('relativeTimeRange', customTimeRange);
+ });
+
+ it('should add query', () => {
+ const initialState: QueriesAndExpressionsState = {
+ queries: [alertQuery],
+ };
+
+ const newState = queriesAndExpressionsReducer(initialState, addNewDataQuery());
+ expect(newState.queries).toHaveLength(2);
+ expect(newState).toMatchSnapshot();
+ });
+
+ it('should set data queries', () => {
+ const initialState: QueriesAndExpressionsState = {
+ queries: [alertQuery, expressionQuery],
+ };
+
+ const newState = queriesAndExpressionsReducer(initialState, setDataQueries([]));
+ expect(newState.queries).toHaveLength(1);
+ expect(newState).toMatchSnapshot();
+ });
+
+ it('should add a new expression', () => {
+ const initialState: QueriesAndExpressionsState = {
+ queries: [alertQuery],
+ };
+
+ const newState = queriesAndExpressionsReducer(initialState, addNewExpression());
+ expect(newState.queries).toHaveLength(2);
+ expect(newState).toMatchSnapshot();
+ });
+
+ it('should remove an expression or alert query', () => {
+ const initialState: QueriesAndExpressionsState = {
+ queries: [alertQuery, expressionQuery],
+ };
+
+ let stateWithoutB = queriesAndExpressionsReducer(initialState, removeExpression('B'));
+ expect(stateWithoutB.queries).toHaveLength(1);
+ expect(stateWithoutB).toMatchSnapshot();
+
+ let stateWithoutAOrB = queriesAndExpressionsReducer(stateWithoutB, removeExpression('A'));
+ expect(stateWithoutAOrB.queries).toHaveLength(0);
+ });
+
+ it('should update an expression', () => {
+ const newExpression: ExpressionQuery = {
+ ...expressionQuery.model,
+ type: ExpressionQueryType.math,
+ };
+
+ const initialState: QueriesAndExpressionsState = {
+ queries: [expressionQuery],
+ };
+
+ const newState = queriesAndExpressionsReducer(initialState, updateExpression(newExpression));
+ expect(newState).toMatchSnapshot();
+ });
+
+ it('should update an expression refId and rewire expressions', () => {
+ const initialState: QueriesAndExpressionsState = {
+ queries: [alertQuery, expressionQuery],
+ };
+
+ const newState = queriesAndExpressionsReducer(
+ initialState,
+ updateExpressionRefId({
+ oldRefId: 'A',
+ newRefId: 'C',
+ })
+ );
+
+ expect(newState).toMatchSnapshot();
+ });
+
+ it('should not update an expression when the refId exists', () => {
+ const initialState: QueriesAndExpressionsState = {
+ queries: [alertQuery, expressionQuery],
+ };
+
+ const newState = queriesAndExpressionsReducer(
+ initialState,
+ updateExpressionRefId({
+ oldRefId: 'A',
+ newRefId: 'B',
+ })
+ );
+
+ expect(newState).toEqual(initialState);
+ });
+
+ it('should rewire expressions', () => {
+ const initialState: QueriesAndExpressionsState = {
+ queries: [alertQuery, expressionQuery],
+ };
+
+ const newState = queriesAndExpressionsReducer(
+ initialState,
+ rewireExpressions({
+ oldRefId: 'A',
+ newRefId: 'C',
+ })
+ );
+
+ expect(newState).toMatchSnapshot();
+ });
+
+ it('should update expression type', () => {
+ const initialState: QueriesAndExpressionsState = {
+ queries: [alertQuery, expressionQuery],
+ };
+
+ const newState = queriesAndExpressionsReducer(
+ initialState,
+ updateExpressionType({
+ refId: 'B',
+ type: ExpressionQueryType.reduce,
+ })
+ );
+
+ expect(newState).toMatchSnapshot();
+ });
+});
diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts
new file mode 100644
index 00000000000..d5533ffdf7b
--- /dev/null
+++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts
@@ -0,0 +1,163 @@
+import { createAction, createReducer } from '@reduxjs/toolkit';
+
+import { DataQuery, RelativeTimeRange, getDefaultRelativeTimeRange } from '@grafana/data';
+import { getNextRefIdChar } from 'app/core/utils/query';
+import {
+ dataSource as expressionDatasource,
+ ExpressionDatasourceUID,
+} from 'app/features/expressions/ExpressionDatasource';
+import { isExpressionQuery } from 'app/features/expressions/guards';
+import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types';
+import { defaultCondition } from 'app/features/expressions/utils/expressionTypes';
+import { AlertQuery } from 'app/types/unified-alerting-dto';
+
+import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource';
+import { queriesWithUpdatedReferences, refIdExists } from '../util';
+
+export interface QueriesAndExpressionsState {
+ queries: AlertQuery[];
+}
+
+const initialState: QueriesAndExpressionsState = {
+ queries: [],
+};
+
+export const duplicateQuery = createAction('duplicateQuery');
+export const addNewDataQuery = createAction('addNewDataQuery');
+export const setDataQueries = createAction('setDataQueries');
+
+export const addNewExpression = createAction('addNewExpression');
+export const removeExpression = createAction('removeExpression');
+export const updateExpression = createAction('updateExpression');
+export const updateExpressionRefId = createAction<{ oldRefId: string; newRefId: string }>('updateExpressionRefId');
+export const rewireExpressions = createAction<{ oldRefId: string; newRefId: string }>('rewireExpressions');
+export const updateExpressionType = createAction<{ refId: string; type: ExpressionQueryType }>('updateExpressionType');
+
+export const queriesAndExpressionsReducer = createReducer(initialState, (builder) => {
+ // data queries actions
+ builder
+ .addCase(duplicateQuery, (state, { payload }) => {
+ state.queries = addQuery(state.queries, payload);
+ })
+ .addCase(addNewDataQuery, (state) => {
+ const datasource = getDefaultOrFirstCompatibleDataSource();
+ if (!datasource) {
+ return;
+ }
+
+ state.queries = addQuery(state.queries, {
+ datasourceUid: datasource.uid,
+ model: {
+ refId: '',
+ datasource: {
+ type: datasource.type,
+ uid: datasource.uid,
+ },
+ },
+ });
+ })
+ .addCase(setDataQueries, (state, { payload }) => {
+ const expressionQueries = state.queries.filter((query) => isExpressionQuery(query.model));
+ state.queries = [...payload, ...expressionQueries];
+ });
+
+ // expressions actions
+ builder
+ .addCase(addNewExpression, (state) => {
+ state.queries = addQuery(state.queries, {
+ datasourceUid: ExpressionDatasourceUID,
+ model: expressionDatasource.newQuery({
+ type: ExpressionQueryType.math,
+ conditions: [{ ...defaultCondition, query: { params: [] } }],
+ expression: '',
+ }),
+ });
+ })
+ .addCase(removeExpression, (state, { payload }) => {
+ state.queries = state.queries.filter((query) => query.refId !== payload);
+ })
+ .addCase(updateExpression, (state, { payload }) => {
+ state.queries = state.queries.map((query) => {
+ return query.refId === payload.refId
+ ? {
+ ...query,
+ model: payload,
+ }
+ : query;
+ });
+ })
+ .addCase(updateExpressionRefId, (state, { payload }) => {
+ const { newRefId, oldRefId } = payload;
+
+ // if the new refId already exists we just refuse to update the state
+ const newRefIdExists = refIdExists(state.queries, newRefId);
+ if (newRefIdExists) {
+ return;
+ }
+
+ const updatedQueries = queriesWithUpdatedReferences(state.queries, oldRefId, newRefId);
+ state.queries = updatedQueries.map((query) => {
+ if (query.refId === oldRefId) {
+ return {
+ ...query,
+ refId: newRefId,
+ model: {
+ ...query.model,
+ refId: newRefId,
+ },
+ };
+ }
+
+ return query;
+ });
+ })
+ .addCase(rewireExpressions, (state, { payload }) => {
+ state.queries = queriesWithUpdatedReferences(state.queries, payload.oldRefId, payload.newRefId);
+ })
+ .addCase(updateExpressionType, (state, action) => {
+ state.queries = state.queries.map((query) => {
+ return query.refId === action.payload.refId
+ ? {
+ ...query,
+ model: {
+ ...expressionDatasource.newQuery({
+ type: action.payload.type,
+ conditions: [{ ...defaultCondition, query: { params: [] } }],
+ expression: '',
+ }),
+ refId: action.payload.refId,
+ },
+ }
+ : query;
+ });
+ });
+});
+
+const addQuery = (
+ queries: AlertQuery[],
+ queryToAdd: Pick
+): AlertQuery[] => {
+ const refId = getNextRefIdChar(queries);
+
+ const query: AlertQuery = {
+ ...queryToAdd,
+ refId,
+ queryType: '',
+ model: {
+ ...queryToAdd.model,
+ hide: false,
+ refId,
+ },
+ relativeTimeRange: queryToAdd.relativeTimeRange ?? defaultTimeRange(queryToAdd.model),
+ };
+
+ return [...queries, query];
+};
+
+const defaultTimeRange = (model: DataQuery): RelativeTimeRange | undefined => {
+ if (isExpressionQuery(model)) {
+ return;
+ }
+
+ return getDefaultRelativeTimeRange();
+};
diff --git a/public/app/features/alerting/unified/components/rule-editor/util.ts b/public/app/features/alerting/unified/components/rule-editor/util.ts
index b623941723f..dd5ee24759f 100644
--- a/public/app/features/alerting/unified/components/rule-editor/util.ts
+++ b/public/app/features/alerting/unified/components/rule-editor/util.ts
@@ -1,5 +1,7 @@
import { ValidateResult } from 'react-hook-form';
+import { DataFrame } from '@grafana/data';
+import { isTimeSeries } from '@grafana/data/src/dataframe/utils';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { AlertQuery } from 'app/types/unified-alerting-dto';
@@ -67,6 +69,10 @@ export function updateMathExpressionRefs(expression: string, previousRefId: stri
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)
//
@@ -79,3 +85,25 @@ export function checkForPathSeparator(value: string): ValidateResult {
return true;
}
+
+export function errorFromSeries(series: DataFrame[]): Error | undefined {
+ if (series.length === 0) {
+ return;
+ }
+
+ const isTimeSeriesResults = isTimeSeries(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;
+}
diff --git a/public/app/features/alerting/unified/components/rules/AlertStateTag.tsx b/public/app/features/alerting/unified/components/rules/AlertStateTag.tsx
index 375b07f3aad..510da6f2f1e 100644
--- a/public/app/features/alerting/unified/components/rules/AlertStateTag.tsx
+++ b/public/app/features/alerting/unified/components/rules/AlertStateTag.tsx
@@ -7,8 +7,11 @@ import { alertStateToReadable, alertStateToState } from '../../utils/rules';
import { StateTag } from '../StateTag';
interface Props {
state: PromAlertingRuleState | GrafanaAlertState | GrafanaAlertStateWithReason | AlertState;
+ size?: 'md' | 'sm';
}
-export const AlertStateTag: FC = ({ state }) => (
- {alertStateToReadable(state)}
+export const AlertStateTag: FC = ({ state, size = 'md' }) => (
+
+ {alertStateToReadable(state)}
+
);
diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts
index cdd336af723..4e830e3a608 100644
--- a/public/app/features/alerting/unified/utils/rule-form.ts
+++ b/public/app/features/alerting/unified/utils/rule-form.ts
@@ -198,7 +198,7 @@ export const getDefaultQueries = (): AlertQuery[] => {
const dataSource = getDefaultOrFirstCompatibleDataSource();
if (!dataSource) {
- return [getDefaultExpression('A')];
+ return [...getDefaultExpressions('A', 'B')];
}
const relativeTimeRange = getDefaultRelativeTimeRange();
@@ -213,15 +213,18 @@ export const getDefaultQueries = (): AlertQuery[] => {
hide: false,
},
},
- getDefaultExpression('B'),
+ ...getDefaultExpressions('B', 'C'),
];
};
-const getDefaultExpression = (refId: string): AlertQuery => {
- const model: ExpressionQuery = {
- refId,
+const getDefaultExpressions = (...refIds: [string, string]): AlertQuery[] => {
+ const refOne = refIds[0];
+ const refTwo = refIds[1];
+
+ const reduceExpression: ExpressionQuery = {
+ refId: refIds[0],
hide: false,
- type: ExpressionQueryType.classic,
+ type: ExpressionQueryType.reduce,
datasource: {
uid: ExpressionDatasourceUID,
type: ExpressionDatasourceRef.type,
@@ -230,14 +233,14 @@ const getDefaultExpression = (refId: string): AlertQuery => {
{
type: 'query',
evaluator: {
- params: [3],
+ params: [],
type: EvalFunction.IsAbove,
},
operator: {
type: 'and',
},
query: {
- params: ['A'],
+ params: [refOne],
},
reducer: {
params: [],
@@ -245,15 +248,54 @@ const getDefaultExpression = (refId: string): AlertQuery => {
},
},
],
+ reducer: 'last',
expression: 'A',
};
- return {
- refId,
- datasourceUid: ExpressionDatasourceUID,
- queryType: '',
- model,
+ const thresholdExpression: ExpressionQuery = {
+ refId: refTwo,
+ hide: false,
+ type: ExpressionQueryType.threshold,
+ datasource: {
+ uid: ExpressionDatasourceUID,
+ type: ExpressionDatasourceRef.type,
+ },
+ conditions: [
+ {
+ type: 'query',
+ evaluator: {
+ params: [0],
+ type: EvalFunction.IsAbove,
+ },
+ operator: {
+ type: 'and',
+ },
+ query: {
+ params: [refTwo],
+ },
+ reducer: {
+ params: [],
+ type: 'last',
+ },
+ },
+ ],
+ expression: refOne,
};
+
+ return [
+ {
+ refId: refOne,
+ datasourceUid: ExpressionDatasourceUID,
+ queryType: '',
+ model: reduceExpression,
+ },
+ {
+ refId: refTwo,
+ datasourceUid: ExpressionDatasourceUID,
+ queryType: '',
+ model: thresholdExpression,
+ },
+ ];
};
const dataQueriesToGrafanaQueries = async (
@@ -338,7 +380,14 @@ export const panelToRuleFormValues = async (
}
if (!queries.find((query) => query.datasourceUid === ExpressionDatasourceUID)) {
- queries.push(getDefaultExpression(getNextRefIdChar(queries.map((query) => query.model))));
+ const [reduceExpression, _thresholdExpression] = getDefaultExpressions(getNextRefIdChar(queries), '-');
+ queries.push(reduceExpression);
+
+ const [_reduceExpression, thresholdExpression] = getDefaultExpressions(
+ reduceExpression.refId,
+ getNextRefIdChar(queries)
+ );
+ queries.push(thresholdExpression);
}
const { folderId, folderTitle } = dashboard.meta;
diff --git a/public/app/features/expressions/components/Condition.tsx b/public/app/features/expressions/components/Condition.tsx
index 780bc868a61..3dd3ede7c8d 100644
--- a/public/app/features/expressions/components/Condition.tsx
+++ b/public/app/features/expressions/components/Condition.tsx
@@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css';
import React, { FC, FormEvent } from 'react';
import { GrafanaTheme, SelectableValue } from '@grafana/data';
-import { Button, ButtonSelect, Icon, InlineFieldRow, Input, Select, useStyles } from '@grafana/ui';
+import { Button, ButtonSelect, Icon, InlineFieldRow, Input, Select, Stack, useStyles } from '@grafana/ui';
import alertDef, { EvalFunction } from '../../alerting/state/alertDef';
import { ClassicCondition, ReducerType } from '../types';
@@ -69,65 +69,70 @@ export const Condition: FC = ({ condition, index, onChange, onRemoveCondi
condition.evaluator.type === EvalFunction.IsWithinRange || condition.evaluator.type === EvalFunction.IsOutsideRange;
return (
-
- {index === 0 ? (
- WHEN
- ) : (
- ea.value === condition.operator!.type)}
- />
- )}
-
+
);
};
diff --git a/public/app/features/expressions/components/Math.tsx b/public/app/features/expressions/components/Math.tsx
index 2e798b53e59..a4d9b1986b5 100644
--- a/public/app/features/expressions/components/Math.tsx
+++ b/public/app/features/expressions/components/Math.tsx
@@ -1,14 +1,14 @@
import { css } from '@emotion/css';
import React, { ChangeEvent, FC } from 'react';
-import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
-import { Button, Icon, InlineField, Stack, TextArea, useStyles2 } from '@grafana/ui';
+import { Icon, InlineField, InlineLabel, Stack, TextArea, useStyles2 } from '@grafana/ui';
+import { HoverCard } from 'app/features/alerting/unified/components/HoverCard';
import { ExpressionQuery } from '../types';
interface Props {
- labelWidth: number;
+ labelWidth: number | 'auto';
query: ExpressionQuery;
onChange: (query: ExpressionQuery) => void;
onRunQuery: () => void;
@@ -19,13 +19,11 @@ const mathPlaceholder =
'The sum of two scalar values: $A + $B > 10';
export const Math: FC = ({ labelWidth, onChange, query, onRunQuery }) => {
- const [showHelp, toggleShowHelp] = useToggle(false);
-
const onExpressionChange = (event: ChangeEvent) => {
onChange({ ...query, expression: event.target.value });
};
- const styles = useStyles2((theme) => getStyles(theme, showHelp));
+ const styles = useStyles2(getStyles);
const executeQuery = () => {
if (query.expression) {
@@ -36,93 +34,97 @@ export const Math: FC = ({ labelWidth, onChange, query, onRunQuery }) =>
return (
+
+
+
+ Run math operations on one or more queries. You reference the query by {'${refId}'} ie. $A, $B, $C
+ etc.
+
+ Example: $A + $B
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ >
+
-
-
- Run math operations on one or more queries. You reference the query by {'${refId}'} ie. $A, $B, $C etc.
-
- Example: $A + $B
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
);
};
@@ -142,7 +144,7 @@ const DocumentedFunction = ({ name, description }: DocumentedFunctionProps) => {
);
};
-const getStyles = (theme: GrafanaTheme2, showHelp?: boolean) => ({
+const getStyles = (theme: GrafanaTheme2) => ({
documentationHeader: css`
font-size: ${theme.typography.h5.fontSize};
font-weight: ${theme.typography.h5.fontWeight};
@@ -151,10 +153,12 @@ const getStyles = (theme: GrafanaTheme2, showHelp?: boolean) => ({
color: ${theme.colors.text.link};
`,
documentationContainer: css`
- display: ${showHelp ? 'flex' : 'none'};
+ display: flex;
flex: 1;
flex-direction: column;
gap: ${theme.spacing(2)};
+
+ padding: ${theme.spacing(1)} ${theme.spacing(2)};
`,
documentationFunctions: css`
display: grid;
diff --git a/public/app/features/expressions/components/Reduce.tsx b/public/app/features/expressions/components/Reduce.tsx
index b17f4be1a52..97ebd603104 100644
--- a/public/app/features/expressions/components/Reduce.tsx
+++ b/public/app/features/expressions/components/Reduce.tsx
@@ -6,13 +6,13 @@ import { InlineField, InlineFieldRow, Input, Select } from '@grafana/ui';
import { ExpressionQuery, ExpressionQuerySettings, ReducerMode, reducerMode, reducerTypes } from '../types';
interface Props {
- labelWidth: number;
+ labelWidth?: number | 'auto';
refIds: Array