Alerting: Fix changing datasource and creating new query not using defaults. (#63092)

* Set default query when changing data source or adding new alert query

* Set default query when creating new alert rule

* Set fefault query for cloud and recording rules

* Create hook for getting defaults in AlertRuleForm

* fixing tests

* Use conditionals with 'if' for more clarity and rename lazy to async

* Add loading indicator for default queries

* Fix tests

* Make newModel a sync method and fix tests error

* Use useAsync instead of useEffect for an async call
This commit is contained in:
Sonia Aguilar 2023-02-24 13:43:54 +01:00 committed by GitHub
parent 09fdbb69ec
commit b42f973a7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 441 additions and 77 deletions

View File

@ -19,5 +19,12 @@ export function getDataSourceSrv() {
return {
getList: () => [ds1],
getInstanceSettings: () => ds1,
get: () =>
Promise.resolve({
filterQuery: () => true,
getDefaultQuery: () => ({
expr: 'vector(1)',
}),
}),
};
}

View File

@ -2,6 +2,7 @@ import { screen, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
import { MockDataSourceApi } from 'test/mocks/datasource_srv';
import { byRole, byText } from 'testing-library-selector';
import { setDataSourceSrv } from '@grafana/runtime';
@ -159,7 +160,10 @@ describe('RuleEditor cloud: checking editable data sources', () => {
return null;
});
setDataSourceSrv(new MockDataSourceSrv(dataSources));
const dsServer = new MockDataSourceSrv(dataSources);
jest.spyOn(dsServer, 'get').mockResolvedValue(new MockDataSourceApi('ds'));
setDataSourceSrv(dsServer);
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.searchFolders.mockResolvedValue([]);

View File

@ -1,8 +1,9 @@
import { waitFor, screen, within, waitForElementToBeRemoved } from '@testing-library/react';
import { screen, waitFor, waitForElementToBeRemoved, within } from '@testing-library/react';
import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event';
import React from 'react';
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
import { MockDataSourceApi } from 'test/mocks/datasource_srv';
import { byRole } from 'testing-library-selector';
import { setDataSourceSrv } from '@grafana/runtime';
@ -79,8 +80,10 @@ describe('RuleEditor cloud', () => {
{ alerting: true }
),
};
const dsServer = new MockDataSourceSrv(dataSources);
jest.spyOn(dsServer, 'get').mockResolvedValue(new MockDataSourceApi('ds'));
setDataSourceSrv(new MockDataSourceSrv(dataSources));
setDataSourceSrv(dsServer);
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.api.setRulerRuleGroup.mockResolvedValue();
mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]);

View File

@ -1,4 +1,4 @@
import { render, waitFor, screen, within } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Route } from 'react-router-dom';
@ -25,7 +25,7 @@ import { disableRBAC, mockDataSource, MockDataSourceSrv, mockFolder } from './mo
import { fetchRulerRulesIfNotFetchedYet } from './state/actions';
import * as config from './utils/config';
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import { getDefaultQueries } from './utils/rule-form';
import * as ruleForm from './utils/rule-form';
jest.mock('./components/rule-editor/ExpressionEditor', () => ({
// eslint-disable-next-line react/display-name
@ -46,12 +46,14 @@ jest.mock('app/features/query/components/QueryEditorRow', () => ({
}));
jest.spyOn(config, 'getAllDataSources');
jest.spyOn(ruleForm, 'getDefaultQueriesAsync');
jest.setTimeout(60 * 1000);
const mocks = {
getAllDataSources: jest.mocked(config.getAllDataSources),
searchFolders: jest.mocked(searchFolders),
getDefaultQueriesAsync: jest.mocked(ruleForm.getDefaultQueriesAsync),
api: {
discoverFeatures: jest.mocked(discoverFeatures),
fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup),
@ -119,6 +121,17 @@ describe('RuleEditor grafana managed rules', () => {
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.api.setRulerRuleGroup.mockResolvedValue();
mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]);
mocks.getDefaultQueriesAsync.mockResolvedValue({
queries: [
{
refId: 'A',
relativeTimeRange: { from: 900, to: 1000 },
datasourceUid: 'dsuid',
model: { refId: 'A' },
queryType: 'query',
},
],
});
mocks.api.fetchRulerRules.mockResolvedValue({
[folder.title]: [
{
@ -133,8 +146,16 @@ describe('RuleEditor grafana managed rules', () => {
uid,
namespace_uid: 'abcd',
namespace_id: 1,
condition: 'B',
data: getDefaultQueries(),
condition: 'A',
data: [
{
refId: 'A',
relativeTimeRange: { from: 900, to: 1000 },
datasourceUid: 'dsuid',
model: { refId: 'A' },
queryType: 'query',
},
],
exec_err_state: GrafanaAlertStateDecision.Error,
no_data_state: GrafanaAlertStateDecision.NoData,
title: 'my great new rule',
@ -202,8 +223,16 @@ describe('RuleEditor grafana managed rules', () => {
for: '5m',
grafana_alert: {
uid,
condition: 'B',
data: getDefaultQueries(),
condition: 'A',
data: [
{
refId: 'A',
relativeTimeRange: { from: 900, to: 1000 },
datasourceUid: 'dsuid',
model: { refId: 'A' },
queryType: 'query',
},
],
exec_err_state: GrafanaAlertStateDecision.Error,
is_paused: false,
no_data_state: 'NoData',

View File

@ -1,8 +1,9 @@
import { waitFor, screen, within, waitForElementToBeRemoved } from '@testing-library/react';
import { screen, waitFor, waitForElementToBeRemoved, within } from '@testing-library/react';
import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event';
import React from 'react';
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
import { MockDataSourceApi } from 'test/mocks/datasource_srv';
import { byRole } from 'testing-library-selector';
import { setDataSourceSrv } from '@grafana/runtime';
@ -19,7 +20,6 @@ import { disableRBAC, mockDataSource, MockDataSourceSrv } from './mocks';
import { fetchRulerRulesIfNotFetchedYet } from './state/actions';
import * as config from './utils/config';
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import { getDefaultQueries } from './utils/rule-form';
jest.mock('./components/rule-editor/ExpressionEditor', () => ({
// eslint-disable-next-line react/display-name
@ -74,11 +74,14 @@ describe('RuleEditor grafana managed rules', () => {
name: 'Prom',
isDefault: true,
},
{ alerting: false }
{ alerting: true }
),
};
setDataSourceSrv(new MockDataSourceSrv(dataSources));
const dsServer = new MockDataSourceSrv(dataSources);
jest.spyOn(dsServer, 'get').mockResolvedValue(new MockDataSourceApi('ds'));
setDataSourceSrv(dsServer);
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.api.setRulerRuleGroup.mockResolvedValue();
mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]);
@ -121,6 +124,7 @@ describe('RuleEditor grafana managed rules', () => {
rulerApiEnabled: false,
},
});
renderRuleEditor();
await waitForElementToBeRemoved(screen.getAllByTestId('Spinner'));
@ -142,6 +146,7 @@ describe('RuleEditor grafana managed rules', () => {
// save and check what was sent to backend
await userEvent.click(ui.buttons.save.get());
// 9seg
await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
// 9seg
@ -149,20 +154,107 @@ describe('RuleEditor grafana managed rules', () => {
{ dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' },
'Folder A',
{
interval: '1m',
name: 'group1',
interval: '1m',
rules: [
{
annotations: { description: 'some description' },
labels: { severity: 'warn' },
for: '5m',
grafana_alert: {
condition: 'B',
data: getDefaultQueries(),
exec_err_state: GrafanaAlertStateDecision.Error,
is_paused: false,
no_data_state: 'NoData',
title: 'my great new rule',
condition: 'C',
no_data_state: 'NoData',
exec_err_state: GrafanaAlertStateDecision.Error,
data: [
{
refId: 'A',
relativeTimeRange: {
from: 600,
to: 0,
},
datasourceUid: 'mock-ds-2',
model: {
hide: false,
refId: 'A',
},
queryType: '',
},
{
refId: 'B',
datasourceUid: '__expr__',
queryType: '',
model: {
refId: 'B',
hide: false,
type: 'reduce',
datasource: {
uid: '__expr__',
type: '__expr__',
},
conditions: [
{
type: 'query',
evaluator: {
params: [],
type: 'gt',
},
operator: {
type: 'and',
},
query: {
params: ['B'],
},
reducer: {
params: [],
type: 'last',
},
},
],
reducer: 'last',
expression: 'A',
},
},
{
refId: 'C',
datasourceUid: '__expr__',
queryType: '',
model: {
refId: 'C',
hide: false,
type: 'threshold',
datasource: {
uid: '__expr__',
type: '__expr__',
},
conditions: [
{
type: 'query',
evaluator: {
params: [0],
type: 'gt',
},
operator: {
type: 'and',
},
query: {
params: ['C'],
},
reducer: {
params: [],
type: 'last',
},
},
],
expression: 'B',
},
},
],
is_paused: false,
},
for: '5m',
annotations: {
description: 'some description',
},
labels: {
severity: 'warn',
},
},
],

View File

@ -1,8 +1,9 @@
import { waitFor, screen, within, waitForElementToBeRemoved } from '@testing-library/react';
import { screen, waitFor, waitForElementToBeRemoved, within } from '@testing-library/react';
import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event';
import React from 'react';
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
import { MockDataSourceApi } from 'test/mocks/datasource_srv';
import { byRole, byText } from 'testing-library-selector';
import { setDataSourceSrv } from '@grafana/runtime';
@ -75,7 +76,10 @@ describe('RuleEditor recording rules', () => {
),
};
setDataSourceSrv(new MockDataSourceSrv(dataSources));
const dsServer = new MockDataSourceSrv(dataSources);
jest.spyOn(dsServer, 'get').mockResolvedValue(new MockDataSourceApi('ds'));
setDataSourceSrv(dsServer);
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.api.setRulerRuleGroup.mockResolvedValue();
mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]);

View File

@ -2,23 +2,40 @@ import { css } from '@emotion/css';
import React, { FC, useEffect, useMemo, useState } from 'react';
import { DeepMap, FieldError, FormProvider, useForm, useFormContext, UseFormWatch } from 'react-hook-form';
import { Link } from 'react-router-dom';
import { useAsync } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { logInfo, config } from '@grafana/runtime';
import { Button, ConfirmModal, CustomScrollbar, Spinner, useStyles2, HorizontalGroup, Field, Input } from '@grafana/ui';
import { DataQuery, DataSourceApi, DataSourceJsonData, GrafanaTheme2, UrlQueryMap } from '@grafana/data';
import { config, logInfo } from '@grafana/runtime';
import {
Button,
ConfirmModal,
CustomScrollbar,
Field,
HorizontalGroup,
Input,
LoadingPlaceholder,
Spinner,
useStyles2,
} from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { contextSrv } from 'app/core/core';
import { useCleanup } from 'app/core/hooks/useCleanup';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { useDispatch } from 'app/types';
import { RuleWithLocation } from 'app/types/unified-alerting';
import { AlertQuery, RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { LogMessages, trackNewAlerRuleFormCancelled, trackNewAlerRuleFormError } from '../../Analytics';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { deleteRuleAction, saveRuleFormAction } from '../../state/actions';
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { initialAsyncRequestState } from '../../utils/redux';
import { getDefaultFormValues, getDefaultQueries, rulerRuleToFormValues } from '../../utils/rule-form';
import {
getDefaultFormValues,
getDefaultQueriesAsync,
getInitialDefaultQueries,
rulerRuleToFormValues,
} from '../../utils/rule-form';
import * as ruleId from '../../utils/rule-id';
import { CloudEvaluationBehavior } from './CloudEvaluationBehavior';
@ -73,6 +90,24 @@ type Props = {
prefill?: Partial<RuleFormValues>; // Existing implies we modify existing rule. Prefill only provides default form values
};
export const useGetDefaults = (queryParams: UrlQueryMap, existing: RuleWithLocation<RulerRuleDTO> | undefined) => {
const [defaultDsAndQueries, setDefaultDsAndQueries] = useState<{
queries: AlertQuery[] | null;
ds?: DataSourceApi<DataQuery, DataSourceJsonData, {}>;
}>({ queries: null });
useAsync(async () => {
setDefaultDsAndQueries(await getDefaultQueriesAsync());
}, [existing]);
const defaultsInQueryParams: string = queryParams['defaults'] as string;
const defaultsInQueryParamsObject = useMemo(
() => ({ ...(defaultsInQueryParams ? JSON.parse(defaultsInQueryParams) : {}) }),
[defaultsInQueryParams]
);
return { defaultDsAndQueries, defaultsInQueryParamsObject };
};
export const AlertRuleForm: FC<Props> = ({ existing, prefill }) => {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
@ -84,6 +119,8 @@ export const AlertRuleForm: FC<Props> = ({ existing, prefill }) => {
const returnTo: string = (queryParams['returnTo'] as string | undefined) ?? '/alerting/list';
const [showDeleteModal, setShowDeleteModal] = useState<boolean>(false);
const { defaultDsAndQueries, defaultsInQueryParamsObject } = useGetDefaults(queryParams, existing);
const defaultValues: RuleFormValues = useMemo(() => {
if (existing) {
return rulerRuleToFormValues(existing);
@ -98,13 +135,13 @@ export const AlertRuleForm: FC<Props> = ({ existing, prefill }) => {
return {
...getDefaultFormValues(),
queries: getDefaultQueries(),
queries: getInitialDefaultQueries(),
condition: 'C',
...(queryParams['defaults'] ? JSON.parse(queryParams['defaults'] as string) : {}),
...defaultsInQueryParamsObject,
type: RuleFormType.grafana,
evaluateEvery: evaluateEvery,
};
}, [existing, prefill, queryParams, evaluateEvery]);
}, [existing, prefill, evaluateEvery, defaultsInQueryParamsObject]);
const formAPI = useForm<RuleFormValues>({
mode: 'onSubmit',
@ -112,7 +149,22 @@ export const AlertRuleForm: FC<Props> = ({ existing, prefill }) => {
shouldFocusError: true,
});
const { handleSubmit, watch } = formAPI;
const { handleSubmit, watch, reset } = formAPI;
// only reset once we get some value in defaultDsAndQueries.queries, adding this value.
useEffect(() => {
const shouldReset = !existing && !prefill && defaultDsAndQueries.queries;
if (shouldReset) {
reset({
...getDefaultFormValues(),
queries: defaultDsAndQueries.queries,
condition: 'C',
...defaultsInQueryParamsObject,
type: RuleFormType.grafana,
evaluateEvery: evaluateEvery,
});
}
}, [defaultDsAndQueries.queries, reset, existing, prefill, defaultsInQueryParamsObject, evaluateEvery]);
const type = watch('type');
const dataSourceName = watch('dataSourceName');
@ -194,7 +246,7 @@ export const AlertRuleForm: FC<Props> = ({ existing, prefill }) => {
const evaluateEveryInForm = watch('evaluateEvery');
useEffect(() => setEvaluateEvery(evaluateEveryInForm), [evaluateEveryInForm]);
return (
return defaultDsAndQueries.queries ? (
<FormProvider {...formAPI}>
<form onSubmit={(e) => e.preventDefault()} className={styles.form}>
<HorizontalGroup height="auto" justify="flex-end">
@ -247,7 +299,13 @@ export const AlertRuleForm: FC<Props> = ({ existing, prefill }) => {
<CustomScrollbar autoHeightMin="100%" hideHorizontalTrack={true}>
<div className={styles.contentInner}>
<AlertRuleNameInput />
<QueryAndExpressionsStep editingExistingRule={!!existing} onDataChange={checkAlertCondition} />
<QueryAndExpressionsStep
editingExistingRule={!!existing}
prefill={!!prefill}
onDataChange={checkAlertCondition}
asyncDefaultQueries={defaultDsAndQueries.queries}
asyncDataSource={defaultDsAndQueries.ds}
/>
{showStep2 && (
<>
{type === RuleFormType.grafana ? (
@ -281,6 +339,8 @@ export const AlertRuleForm: FC<Props> = ({ existing, prefill }) => {
) : null}
{showEditYaml ? <RuleInspector onClose={() => setShowEditYaml(false)} /> : null}
</FormProvider>
) : (
<LoadingPlaceholder text={'Loading defaults...'} />
);
};

View File

@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { noop } from 'lodash';
import React, { FC, useCallback, useMemo } from 'react';
import { useAsync } from 'react-use';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { useAsync, usePrevious } from 'react-use';
import { CoreApp, DataQuery, DataSourcePluginContextProvider, GrafanaTheme2, LoadingState } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
@ -9,6 +9,8 @@ import { Alert, Button, useStyles2 } from '@grafana/ui';
import { LokiQuery } from 'app/plugins/datasource/loki/types';
import { PromQuery } from 'app/plugins/datasource/prometheus/types';
import { AlertQuery } from '../../../../../types/unified-alerting-dto';
import { CloudAlertPreview } from './CloudAlertPreview';
import { usePreview } from './PreviewRule';
@ -17,6 +19,8 @@ export interface ExpressionEditorProps {
onChange: (value: string) => void;
dataSourceName: string; // will be a prometheus or loki datasource
showPreviewAlertsButton: boolean;
asyncDefaultQuery?: AlertQuery;
preservePreviousValue: boolean;
}
export const ExpressionEditor: FC<ExpressionEditorProps> = ({
@ -24,11 +28,31 @@ export const ExpressionEditor: FC<ExpressionEditorProps> = ({
onChange,
dataSourceName,
showPreviewAlertsButton = true,
asyncDefaultQuery,
preservePreviousValue,
}) => {
const styles = useStyles2(getStyles);
const { mapToValue, mapToQuery } = useQueryMappers(dataSourceName);
const dataQuery = mapToQuery({ refId: 'A', hide: false }, value);
const [dataQuery, setDataQuery] = useState(mapToQuery({ refId: 'A', hide: false }, value));
const defaultModel = asyncDefaultQuery?.model;
const previousDataSource = usePrevious(dataSourceName);
// New alert: update with default query once we have the async default value
useEffect(() => {
const shouldSetDefaultQuery = !preservePreviousValue && defaultModel;
if (shouldSetDefaultQuery) {
setDataQuery((dataQuery) => ({ ...dataQuery, ...{ ...defaultModel } }));
}
}, [defaultModel, preservePreviousValue]);
// when data source is changed
useEffect(() => {
const shouldSetDefaultQuery =
!!previousDataSource && previousDataSource !== dataSourceName && Boolean(dataSourceName) && defaultModel;
if (shouldSetDefaultQuery) {
setDataQuery((dataQuery) => ({ ...dataQuery, ...{ ...defaultModel } }));
}
}, [dataSourceName, defaultModel, previousDataSource]);
const {
error,
@ -71,7 +95,7 @@ export const ExpressionEditor: FC<ExpressionEditorProps> = ({
// The preview API returns arrays with empty elements when there are no firing alerts
const previewHasAlerts = previewDataFrame && previewDataFrame.fields.some((field) => field.values.length > 0);
return (
return dataQuery ? (
<>
<DataSourcePluginContextProvider instanceSettings={dsi}>
<QueryEditor
@ -101,7 +125,7 @@ export const ExpressionEditor: FC<ExpressionEditorProps> = ({
</div>
)}
</>
);
) : null;
};
const getStyles = (theme: GrafanaTheme2) => ({

View File

@ -2,7 +2,16 @@ import { omit } from 'lodash';
import React, { PureComponent, useState } from 'react';
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
import { DataQuery, DataSourceInstanceSettings, LoadingState, PanelData, RelativeTimeRange } from '@grafana/data';
import {
CoreApp,
DataQuery,
DataSourceApi,
DataSourceInstanceSettings,
DataSourceJsonData,
LoadingState,
PanelData,
RelativeTimeRange,
} from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { Button, Card, Icon } from '@grafana/ui';
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
@ -66,22 +75,30 @@ export class QueryRows extends PureComponent<Props> {
);
};
onChangeDataSource = (settings: DataSourceInstanceSettings, index: number) => {
onChangeDataSource = async (settings: DataSourceInstanceSettings, index: number) => {
const { queries, onQueriesChange } = this.props;
const updatedQueries = queries.map((item, itemIndex) => {
if (itemIndex !== index) {
return item;
}
const updatedQueries = await Promise.all(
queries.map(async (item, itemIndex) => {
if (itemIndex !== index) {
return item;
}
const previousSettings = this.getDataSourceSettings(item);
const previousSettings = this.getDataSourceSettings(item);
// Copy model if changing to a datasource of same type.
if (settings.type === previousSettings?.type) {
return copyModel(item, settings);
}
return newModel(item, settings);
});
// Copy model if changing to a datasource of same type.
if (settings.type === previousSettings?.type) {
return copyModel(item, settings);
}
let ds;
try {
ds = await getDataSourceSrv().get(settings.uid); // get new ds
} catch (e) {
return newModel(item, settings);
}
return newModel(item, settings, ds);
})
);
onQueriesChange(updatedQueries);
};
@ -218,13 +235,34 @@ function copyModel(item: AlertQuery, settings: DataSourceInstanceSettings): Omit
};
}
function newModel(item: AlertQuery, settings: DataSourceInstanceSettings): Omit<AlertQuery, 'datasource'> {
function newModel(
item: AlertQuery,
settings: DataSourceInstanceSettings,
ds?: DataSourceApi<DataQuery, DataSourceJsonData, {}>
): Omit<AlertQuery, 'datasource'> {
if (!ds) {
return {
refId: item.refId,
relativeTimeRange: item.relativeTimeRange,
queryType: '',
datasourceUid: settings.uid,
model: {
refId: item.refId,
hide: false,
datasource: {
type: settings.type,
uid: settings.uid,
},
},
};
}
return {
refId: item.refId,
relativeTimeRange: item.relativeTimeRange,
queryType: '',
datasourceUid: settings.uid,
model: {
...ds?.getDefaultQuery?.(CoreApp.UnifiedAlerting),
refId: item.refId,
hide: false,
datasource: {

View File

@ -1,7 +1,7 @@
import React, { FC, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { LoadingState, PanelData } from '@grafana/data';
import { DataQuery, DataSourceApi, DataSourceJsonData, LoadingState, PanelData } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Stack } from '@grafana/experimental';
import { config } from '@grafana/runtime';
@ -35,16 +35,25 @@ import {
interface Props {
editingExistingRule: boolean;
prefill: boolean;
onDataChange: (error: string) => void;
asyncDefaultQueries?: AlertQuery[];
asyncDataSource?: DataSourceApi<DataQuery, DataSourceJsonData, {}>;
}
export const QueryAndExpressionsStep: FC<Props> = ({ editingExistingRule, onDataChange }) => {
export const QueryAndExpressionsStep: FC<Props> = ({
editingExistingRule,
onDataChange,
asyncDefaultQueries,
asyncDataSource,
prefill,
}) => {
const runner = useRef(new AlertingQueryRunner());
const {
setValue,
getValues,
watch,
formState: { errors },
formState: { errors, isDirty },
control,
} = useFormContext<RuleFormValues>();
const [panelData, setPanelData] = useState<Record<string, PanelData>>({});
@ -54,7 +63,6 @@ export const QueryAndExpressionsStep: FC<Props> = ({ editingExistingRule, onData
panelData: {},
};
const [{ queries }, dispatch] = useReducer(queriesAndExpressionsReducer, initialState);
const [type, condition, dataSourceName] = watch(['type', 'condition', 'dataSourceName']);
const isGrafanaManagedType = type === RuleFormType.grafana;
@ -71,6 +79,15 @@ export const QueryAndExpressionsStep: FC<Props> = ({ editingExistingRule, onData
runner.current.run(getValues('queries'));
}, [getValues]);
const updateWithDefault = !editingExistingRule && !prefill;
//once default queries is updated
useEffect(() => {
const shouldSetDataQuery = updateWithDefault && !isDirty && asyncDefaultQueries;
if (shouldSetDataQuery) {
dispatch(setDataQueries(asyncDefaultQueries));
}
}, [asyncDefaultQueries, updateWithDefault, isDirty]);
// whenever we update the queries we have to update the form too
useEffect(() => {
setValue('queries', queries, { shouldValidate: false });
@ -176,6 +193,13 @@ export const QueryAndExpressionsStep: FC<Props> = ({ editingExistingRule, onData
}
}, [condition, queries, handleSetCondition]);
const defaultQueryAvailable = asyncDataSource && asyncDefaultQueries;
const onAddNewQuery = () => {
if (defaultQueryAvailable) {
dispatch(addNewDataQuery({ ds: asyncDataSource, defaultQuery: asyncDefaultQueries[0]?.model }));
}
};
return (
<RuleEditorSection stepNo={2} title="Set a query and alert condition">
<AlertType editingExistingRule={editingExistingRule} />
@ -191,6 +215,8 @@ export const QueryAndExpressionsStep: FC<Props> = ({ editingExistingRule, onData
{...field}
dataSourceName={dataSourceName}
showPreviewAlertsButton={!isRecordingRuleType}
asyncDefaultQuery={asyncDefaultQueries?.[0]}
preservePreviousValue={!updateWithDefault}
/>
);
}}
@ -239,9 +265,7 @@ export const QueryAndExpressionsStep: FC<Props> = ({ editingExistingRule, onData
<Button
type="button"
icon="plus"
onClick={() => {
dispatch(addNewDataQuery());
}}
onClick={onAddNewQuery}
variant="secondary"
aria-label={selectors.components.QueryTab.addQuery}
disabled={noCompatibleDataSources}

View File

@ -66,14 +66,24 @@ exports[`Query and expressions reducer should add query 1`] = `
"refId": "A",
},
{
"datasourceUid": "c8eceabb-0275-4108-8f03-8f74faf4bf6d",
"datasourceUid": "some uid",
"model": {
"datasource": {
"type": "prometheus",
"uid": "c8eceabb-0275-4108-8f03-8f74faf4bf6d",
"type": undefined,
"uid": "some uid",
},
"datasourceUid": "",
"hide": false,
"model": {
"expression": "THIS IS THE DEFAULT EXPRESSION",
"refId": "A",
},
"queryType": "",
"refId": "B",
"relativeTimeRange": {
"from": 600,
"to": 0,
},
},
"queryType": "",
"refId": "B",

View File

@ -1,4 +1,4 @@
import { getDefaultRelativeTimeRange, RelativeTimeRange } from '@grafana/data';
import { DataSourceApi, getDefaultRelativeTimeRange, PluginMeta, RelativeTimeRange } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime/src/services/__mocks__/dataSourceSrv';
import {
dataSource as expressionDatasource,
@ -8,6 +8,9 @@ import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/t
import { defaultCondition } from 'app/features/expressions/utils/expressionTypes';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { mockDataSource } from '../../../mocks';
import * as dataSource from '../../../utils/datasource';
import {
addNewDataQuery,
addNewExpression,
@ -49,6 +52,12 @@ const expressionQuery: AlertQuery = {
queryType: '',
};
const defaultDataSource = mockDataSource();
const dataSourceMocked = jest.spyOn(dataSource, 'getDefaultOrFirstCompatibleDataSource');
beforeAll(() => {
return dataSourceMocked.mockReturnValue(defaultDataSource);
});
describe('Query and expressions reducer', () => {
it('should return initial state', () => {
expect(queriesAndExpressionsReducer(undefined, { type: undefined })).toEqual({
@ -96,7 +105,23 @@ describe('Query and expressions reducer', () => {
queries: [alertQuery],
};
const newState = queriesAndExpressionsReducer(initialState, addNewDataQuery());
const newDataSource: DataSourceApi = {
meta: { alerting: true } as unknown as PluginMeta,
name: 'some name',
uid: 'some uid',
} as unknown as DataSourceApi;
const defaultQuery: AlertQuery = {
refId: 'A',
queryType: '',
datasourceUid: '',
model: { refId: 'A', expression: 'THIS IS THE DEFAULT EXPRESSION' },
relativeTimeRange: getDefaultRelativeTimeRange(),
};
const newState = queriesAndExpressionsReducer(
initialState,
addNewDataQuery({ ds: newDataSource, defaultQuery: defaultQuery })
);
expect(newState.queries).toHaveLength(2);
expect(newState).toMatchSnapshot();
});

View File

@ -1,6 +1,12 @@
import { createAction, createReducer } from '@reduxjs/toolkit';
import { DataQuery, getDefaultRelativeTimeRange, RelativeTimeRange } from '@grafana/data';
import {
DataQuery,
DataSourceApi,
DataSourceJsonData,
getDefaultRelativeTimeRange,
RelativeTimeRange,
} from '@grafana/data';
import { getNextRefIdChar } from 'app/core/utils/query';
import { findDataSourceFromExpressionRecursive } from 'app/features/alerting/utils/dataSourceFromExpression';
import {
@ -12,7 +18,6 @@ import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/t
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 {
@ -33,7 +38,10 @@ const initialState: QueriesAndExpressionsState = {
};
export const duplicateQuery = createAction<AlertQuery>('duplicateQuery');
export const addNewDataQuery = createAction('addNewDataQuery');
export const addNewDataQuery = createAction<{
ds: DataSourceApi<DataQuery, DataSourceJsonData, {}>;
defaultQuery: Partial<DataQuery> | undefined;
}>('addNewDataQuery');
export const setDataQueries = createAction<AlertQuery[]>('setDataQueries');
export const addNewExpression = createAction('addNewExpression');
@ -51,20 +59,17 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder
.addCase(duplicateQuery, (state, { payload }) => {
state.queries = addQuery(state.queries, payload);
})
.addCase(addNewDataQuery, (state) => {
const datasource = getDefaultOrFirstCompatibleDataSource();
if (!datasource) {
return;
}
.addCase(addNewDataQuery, (state, { payload }) => {
const datasource = payload.ds;
state.queries = addQuery(state.queries, {
datasourceUid: datasource.uid,
model: {
refId: '',
datasource: {
type: datasource.type,
uid: datasource.uid,
},
...payload.defaultQuery,
refId: '',
},
});
})

View File

@ -1,5 +1,8 @@
import {
CoreApp,
DataQuery,
DataSourceApi,
DataSourceJsonData,
DataSourceRef,
getDefaultRelativeTimeRange,
IntervalValues,
@ -199,9 +202,46 @@ export function recordingRulerRuleToRuleForm(
};
}
export const getDefaultQueries = (): AlertQuery[] => {
export const getDefaultQueriesAsync = async (): Promise<{
queries: AlertQuery[] | null;
ds?: DataSourceApi<DataQuery, DataSourceJsonData, {}>;
}> => {
const dataSource = getDefaultOrFirstCompatibleDataSource();
if (!dataSource) {
return { queries: [...getDefaultExpressions('A', 'B')] };
}
const relativeTimeRange = getDefaultRelativeTimeRange();
let ds;
try {
ds = await getDataSourceSrv().get(dataSource.uid);
} catch (error) {
return { queries: [...getDefaultExpressions('A', 'B')] };
}
return {
queries: [
{
refId: 'A',
datasourceUid: dataSource.uid,
queryType: '',
relativeTimeRange,
model: {
refId: 'A',
hide: false,
...ds?.getDefaultQuery?.(CoreApp.UnifiedAlerting),
},
},
...getDefaultExpressions('B', 'C'),
],
ds: ds,
};
};
// Needed to init default queries before the async call
export const getInitialDefaultQueries = (): AlertQuery[] => {
const dataSource = getDefaultOrFirstCompatibleDataSource();
if (!dataSource) {
return [...getDefaultExpressions('A', 'B')];
}
@ -218,7 +258,6 @@ export const getDefaultQueries = (): AlertQuery[] => {
hide: false,
},
},
...getDefaultExpressions('B', 'C'),
];
};