Alerting: Add smart type selection when creating a new alert rule (#71071)

* Add smart type selection when creating a new alert rule

* Auto switch when switch button has not been clicked yet

* remove unnecessay code after the last refacgtor

* Refactor

* Remove unneeded prop

* Move SmartAlertTypeDetector to its own file

* Fix tests

* Refactor: new useSetExpressionAndDataSource hook

* Fix expressions not been propagated when switching from one type to another

* Change texts

* Update tests

* Update text in switch button

* Update texts and tests

* Refactor: move code to getCanSwitch new method

* Move smart alert after queries and remove auto-switch

* Remove expressions and restore them when switching between grafana and cloud type

* Rename previous expression state

* Fix tests

* Add data source name for data source-managed alert selection

* Update reducer when changing cloud data source

* PR review suggestions

* PR review suggestions 2nd part

* PR review suggestions 3th part

* Fix canSwitch

* Update texts on smart alert

---------

Co-authored-by: Virginia Cepeda <virginia.cepeda@grafana.com>
This commit is contained in:
Sonia Aguilar 2023-07-13 17:02:47 +02:00 committed by GitHub
parent bb48417ba0
commit 718401d250
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 460 additions and 233 deletions

View File

@ -36,6 +36,75 @@ jest.mock('app/features/query/components/QueryEditorRow', () => ({
QueryEditorRow: () => <p>hi</p>,
}));
jest.mock('./components/rule-editor/util', () => {
const originalModule = jest.requireActual('./components/rule-editor/util');
return {
...originalModule,
getThresholdsForQueries: jest.fn(() => ({})),
};
});
const dataSources = {
// can edit rules
loki: mockDataSource(
{
type: DataSourceType.Loki,
name: 'loki with ruler',
},
{ alerting: true }
),
loki_disabled: mockDataSource(
{
type: DataSourceType.Loki,
name: 'loki disabled for alerting',
jsonData: {
manageAlerts: false,
},
},
{ alerting: true }
),
// can edit rules
prom: mockDataSource(
{
type: DataSourceType.Prometheus,
name: 'cortex with ruler',
},
{ alerting: true }
),
// cannot edit rules
loki_local_rule_store: mockDataSource(
{
type: DataSourceType.Loki,
name: 'loki with local rule store',
},
{ alerting: true }
),
// cannot edit rules
prom_no_ruler_api: mockDataSource(
{
type: DataSourceType.Loki,
name: 'cortex without ruler api',
},
{ alerting: true }
),
// not a supported datasource type
splunk: mockDataSource(
{
type: 'splunk',
name: 'splunk',
},
{ alerting: true }
),
};
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: jest.fn(() => ({
getInstanceSettings: () => dataSources.prom,
get: () => dataSources.prom,
})),
}));
jest.spyOn(config, 'getAllDataSources');
const mocks = {
@ -74,59 +143,6 @@ describe('RuleEditor cloud: checking editable data sources', () => {
disableRBAC();
it('for cloud alerts, should only allow to select editable rules sources', async () => {
const dataSources = {
// can edit rules
loki: mockDataSource(
{
type: DataSourceType.Loki,
name: 'loki with ruler',
},
{ alerting: true }
),
loki_disabled: mockDataSource(
{
type: DataSourceType.Loki,
name: 'loki disabled for alerting',
jsonData: {
manageAlerts: false,
},
},
{ alerting: true }
),
// can edit rules
prom: mockDataSource(
{
type: DataSourceType.Prometheus,
name: 'cortex with ruler',
},
{ alerting: true }
),
// cannot edit rules
loki_local_rule_store: mockDataSource(
{
type: DataSourceType.Loki,
name: 'loki with local rule store',
},
{ alerting: true }
),
// cannot edit rules
prom_no_ruler_api: mockDataSource(
{
type: DataSourceType.Loki,
name: 'cortex without ruler api',
},
{ alerting: true }
),
// not a supported datasource type
splunk: mockDataSource(
{
type: 'splunk',
name: 'splunk',
},
{ alerting: true }
),
};
mocks.api.discoverFeatures.mockImplementation(async (dataSourceName) => {
if (dataSourceName === 'loki with ruler' || dataSourceName === 'cortex with ruler') {
return getDiscoverFeaturesMock(PromApplication.Cortex, { rulerApiEnabled: true });
@ -168,11 +184,22 @@ describe('RuleEditor cloud: checking editable data sources', () => {
await waitForElementToBeRemoved(screen.getAllByTestId('Spinner'));
await ui.inputs.name.find();
await userEvent.click(await ui.buttons.lotexAlert.get());
const removeExpressionsButtons = screen.getAllByLabelText('Remove expression');
expect(removeExpressionsButtons).toHaveLength(2);
const switchToCloudButton = screen.getByText('Switch to data source-managed alert rule');
expect(switchToCloudButton).toBeInTheDocument();
await userEvent.click(switchToCloudButton);
//expressions are removed after switching to data-source managed
expect(screen.queryAllByLabelText('Remove expression')).toHaveLength(0);
// check that only rules sources that have ruler available are there
const dataSourceSelect = ui.inputs.dataSource.get();
await userEvent.click(byRole('combobox').get(dataSourceSelect));
expect(await byText('loki with ruler').query()).toBeInTheDocument();
expect(byText('cortex with ruler').query()).toBeInTheDocument();
expect(byText('loki with local rule store').query()).not.toBeInTheDocument();

View File

@ -1,4 +1,4 @@
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';
@ -36,6 +36,33 @@ jest.mock('app/features/query/components/QueryEditorRow', () => ({
QueryEditorRow: () => <p>hi</p>,
}));
jest.mock('./components/rule-editor/util', () => {
const originalModule = jest.requireActual('./components/rule-editor/util');
return {
...originalModule,
getThresholdsForQueries: jest.fn(() => ({})),
};
});
const dataSources = {
default: mockDataSource(
{
type: 'prometheus',
name: 'Prom',
isDefault: true,
},
{ alerting: true }
),
};
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: jest.fn(() => ({
getInstanceSettings: () => dataSources.default,
get: () => dataSources.default,
})),
}));
jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) => <div>{actions}</div>,
}));
@ -73,17 +100,6 @@ describe('RuleEditor cloud', () => {
disableRBAC();
it('can create a new cloud alert', async () => {
const dataSources = {
default: mockDataSource(
{
type: 'prometheus',
name: 'Prom',
isDefault: true,
},
{ alerting: true }
),
};
setDataSourceSrv(new MockDataSourceSrv(dataSources));
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.api.setRulerRuleGroup.mockResolvedValue();
@ -118,7 +134,18 @@ describe('RuleEditor cloud', () => {
renderRuleEditor();
await waitForElementToBeRemoved(screen.getAllByTestId('Spinner'));
await userEvent.click(await ui.buttons.lotexAlert.find());
const removeExpressionsButtons = screen.getAllByLabelText('Remove expression');
expect(removeExpressionsButtons).toHaveLength(2);
const switchToCloudButton = screen.getByText('Switch to data source-managed alert rule');
expect(switchToCloudButton).toBeInTheDocument();
await userEvent.click(switchToCloudButton);
//expressions are removed after switching to data-source managed
expect(screen.queryAllByLabelText('Remove expression')).toHaveLength(0);
expect(screen.getByTestId('datasource-picker')).toBeInTheDocument();
const dataSourceSelect = ui.inputs.dataSource.get();
await userEvent.click(byRole('combobox').get(dataSourceSelect));

View File

@ -1,91 +0,0 @@
import { render } from '@testing-library/react';
import React from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { byText } from 'testing-library-selector';
import { locationService } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { configureStore } from 'app/store/configureStore';
import { AccessControlAction } from 'app/types';
import { AlertType } from './AlertType';
const ui = {
ruleTypePicker: {
grafanaManagedButton: byText('Grafana managed alert'),
mimirOrLokiButton: byText('Mimir or Loki alert'),
mimirOrLokiRecordingButton: byText('Mimir or Loki recording rule'),
},
};
const FormProviderWrapper = ({ children }: React.PropsWithChildren<{}>) => {
const methods = useForm({});
return <FormProvider {...methods}>{children}</FormProvider>;
};
function renderAlertTypeStep() {
const store = configureStore();
render(
<Provider store={store}>
<Router history={locationService.getHistory()}>
<AlertType editingExistingRule={false} />
</Router>
</Provider>,
{ wrapper: FormProviderWrapper }
);
}
describe('RuleTypePicker', () => {
describe('RBAC', () => {
it('Should display grafana and mimir alert when user has rule create and write permissions', async () => {
jest.spyOn(contextSrv, 'hasPermission').mockImplementation((action) => {
return [AccessControlAction.AlertingRuleCreate, AccessControlAction.AlertingRuleExternalWrite].includes(
action as AccessControlAction
);
});
renderAlertTypeStep();
expect(ui.ruleTypePicker.grafanaManagedButton.get()).toBeInTheDocument();
expect(ui.ruleTypePicker.mimirOrLokiButton.get()).toBeInTheDocument();
});
it('Should not display the recording rule button', async () => {
jest.spyOn(contextSrv, 'hasPermission').mockImplementation((action) => {
return [AccessControlAction.AlertingRuleCreate, AccessControlAction.AlertingRuleExternalWrite].includes(
action as AccessControlAction
);
});
renderAlertTypeStep();
expect(ui.ruleTypePicker.mimirOrLokiRecordingButton.query()).not.toBeInTheDocument();
});
it('Should hide grafana button when user does not have rule create permission', () => {
jest.spyOn(contextSrv, 'hasPermission').mockImplementation((action) => {
return [AccessControlAction.AlertingRuleExternalWrite].includes(action as AccessControlAction);
});
renderAlertTypeStep();
expect(ui.ruleTypePicker.grafanaManagedButton.query()).not.toBeInTheDocument();
expect(ui.ruleTypePicker.mimirOrLokiButton.get()).toBeInTheDocument();
expect(ui.ruleTypePicker.mimirOrLokiRecordingButton.query()).not.toBeInTheDocument();
});
it('Should hide mimir alert and mimir recording when user does not have rule external write permission', () => {
jest.spyOn(contextSrv, 'hasPermission').mockImplementation((action) => {
return [AccessControlAction.AlertingRuleCreate].includes(action as AccessControlAction);
});
renderAlertTypeStep();
expect(ui.ruleTypePicker.grafanaManagedButton.get()).toBeInTheDocument();
expect(ui.ruleTypePicker.mimirOrLokiButton.query()).not.toBeInTheDocument();
expect(ui.ruleTypePicker.mimirOrLokiRecordingButton.query()).not.toBeInTheDocument();
});
});
});

View File

@ -4,24 +4,17 @@ import { useFormContext } from 'react-hook-form';
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
import { Field, InputControl, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types';
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
import { CloudRulesSourcePicker } from '../CloudRulesSourcePicker';
import { RuleTypePicker } from '../rule-types/RuleTypePicker';
interface Props {
editingExistingRule: boolean;
export interface CloudDataSourceSelectorProps {
onChangeCloudDatasource: (datasourceUid: string) => void;
}
export const AlertType = ({ editingExistingRule }: Props) => {
const { enabledRuleTypes, defaultRuleType } = getAvailableRuleTypes();
export const CloudDataSourceSelector = ({ onChangeCloudDatasource }: CloudDataSourceSelectorProps) => {
const {
control,
formState: { errors },
getValues,
setValue,
watch,
} = useFormContext<RuleFormValues & { location?: string }>();
@ -31,26 +24,6 @@ export const AlertType = ({ editingExistingRule }: Props) => {
return (
<>
{!editingExistingRule && ruleFormType !== RuleFormType.cloudRecording && (
<Field error={errors.type?.message} invalid={!!errors.type?.message} data-testid="alert-type-picker">
<InputControl
render={({ field: { onChange } }) => (
<RuleTypePicker
aria-label="Rule type"
selected={getValues('type') ?? defaultRuleType}
onChange={onChange}
enabledTypes={enabledRuleTypes}
/>
)}
name="type"
control={control}
rules={{
required: { value: true, message: 'Please select alert type' },
}}
/>
</Field>
)}
<div className={styles.flexRow}>
{(ruleFormType === RuleFormType.cloudAlerting || ruleFormType === RuleFormType.cloudRecording) && (
<Field
@ -70,6 +43,7 @@ export const AlertType = ({ editingExistingRule }: Props) => {
// reset expression as they don't need to persist after changing datasources
setValue('expression', '');
onChange(ds?.name ?? null);
onChangeCloudDatasource(ds?.uid ?? null);
}}
/>
)}
@ -86,25 +60,6 @@ export const AlertType = ({ editingExistingRule }: Props) => {
);
};
function getAvailableRuleTypes() {
const canCreateGrafanaRules = contextSrv.hasAccess(
AccessControlAction.AlertingRuleCreate,
contextSrv.hasEditPermissionInFolders
);
const canCreateCloudRules = contextSrv.hasAccess(AccessControlAction.AlertingRuleExternalWrite, contextSrv.isEditor);
const defaultRuleType = canCreateGrafanaRules ? RuleFormType.grafana : RuleFormType.cloudAlerting;
const enabledRuleTypes: RuleFormType[] = [];
if (canCreateGrafanaRules) {
enabledRuleTypes.push(RuleFormType.grafana);
}
if (canCreateCloudRules) {
enabledRuleTypes.push(RuleFormType.cloudAlerting, RuleFormType.cloudRecording);
}
return { enabledRuleTypes, defaultRuleType };
}
const getStyles = (theme: GrafanaTheme2) => ({
formInput: css`
width: 330px;

View File

@ -1,5 +1,6 @@
import { css } from '@emotion/css';
import React, { useCallback, useEffect, useMemo, useReducer } from 'react';
import { cloneDeep } from 'lodash';
import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { getDefaultRelativeTimeRange, GrafanaTheme2 } from '@grafana/data';
@ -9,13 +10,15 @@ import { config, getDataSourceSrv } from '@grafana/runtime';
import { Alert, Button, Dropdown, Field, Icon, InputControl, Menu, MenuItem, Tooltip, useStyles2 } from '@grafana/ui';
import { H5 } from '@grafana/ui/src/unstable';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { ExpressionQueryType, expressionTypes } from 'app/features/expressions/types';
import { ExpressionDatasourceUID, ExpressionQueryType, expressionTypes } from 'app/features/expressions/types';
import { useDispatch } from 'app/types';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { useRulesSourcesWithRuler } from '../../../hooks/useRuleSourcesWithRuler';
import { fetchAllPromBuildInfoAction } from '../../../state/actions';
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource';
import { isPromOrLokiQuery } from '../../../utils/rule-form';
import { isPromOrLokiQuery, PromOrLokiQuery } from '../../../utils/rule-form';
import { ExpressionEditor } from '../ExpressionEditor';
import { ExpressionsEditor } from '../ExpressionsEditor';
import { NeedHelpInfo } from '../NeedHelpInfo';
@ -24,13 +27,16 @@ import { RecordingRuleEditor } from '../RecordingRuleEditor';
import { RuleEditorSection } from '../RuleEditorSection';
import { errorFromSeries, refIdExists } from '../util';
import { AlertType } from './AlertType';
import { CloudDataSourceSelector } from './CloudDataSourceSelector';
import { SmartAlertTypeDetector } from './SmartAlertTypeDetector';
import {
addExpressions,
addNewDataQuery,
addNewExpression,
duplicateQuery,
queriesAndExpressionsReducer,
removeExpression,
removeExpressions,
rewireExpressions,
setDataQueries,
setRecordingRulesQueries,
@ -68,6 +74,11 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
const isRecordingRuleType = type === RuleFormType.cloudRecording;
const isCloudAlertRuleType = type === RuleFormType.cloudAlerting;
const dispatchReduxAction = useDispatch();
useEffect(() => {
dispatchReduxAction(fetchAllPromBuildInfoAction());
}, [dispatchReduxAction]);
const rulesSourcesWithRuler = useRulesSourcesWithRuler();
const runQueriesPreview = useCallback(() => {
@ -135,6 +146,8 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
[condition, queries, handleSetCondition]
);
const updateExpressionAndDatasource = useSetExpressionAndDataSource();
const onChangeQueries = useCallback(
(updatedQueries: AlertQuery[]) => {
// Most data sources triggers onChange and onRunQueries consecutively
@ -144,6 +157,8 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
// This way we can access up to date queries in runQueriesPreview without waiting for re-render
setValue('queries', updatedQueries, { shouldValidate: false });
updateExpressionAndDatasource(updatedQueries);
dispatch(setDataQueries(updatedQueries));
dispatch(updateExpressionTimeRange());
// check if we need to rewire expressions
@ -156,7 +171,7 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
}
});
},
[queries, setValue]
[queries, setValue, updateExpressionAndDatasource]
);
const onChangeRecordingRulesQueries = useCallback(
@ -236,9 +251,84 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
const styles = useStyles2(getStyles);
// Cloud alerts load data from form values
// whereas Grafana managed alerts load data from reducer
//when data source is changed in the cloud selector we need to update the queries in the reducer
const onChangeCloudDatasource = useCallback(
(datasourceUid: string) => {
const newQueries = cloneDeep(queries);
newQueries[0].datasourceUid = datasourceUid;
setValue('queries', newQueries, { shouldValidate: false });
updateExpressionAndDatasource(newQueries);
dispatch(setDataQueries(newQueries));
},
[queries, setValue, updateExpressionAndDatasource, dispatch]
);
// ExpressionEditor for cloud query needs to update queries in the reducer and in the form
// otherwise the value is not updated for Grafana managed alerts
const onChangeExpression = (value: string) => {
const newQueries = cloneDeep(queries);
if (newQueries[0].model) {
if (isPromOrLokiQuery(newQueries[0].model)) {
newQueries[0].model.expr = value;
} else {
// first time we come from grafana-managed type
// we need to convert the model to PromOrLokiQuery
const promLoki: PromOrLokiQuery = {
...cloneDeep(newQueries[0].model),
expr: value,
};
newQueries[0].model = promLoki;
}
}
setValue('queries', newQueries, { shouldValidate: false });
updateExpressionAndDatasource(newQueries);
dispatch(setDataQueries(newQueries));
runQueriesPreview();
};
const removeExpressionsInQueries = useCallback(() => dispatch(removeExpressions()), [dispatch]);
const addExpressionsInQueries = useCallback(
(expressions: AlertQuery[]) => dispatch(addExpressions(expressions)),
[dispatch]
);
// we need to keep track of the previous expressions to be able to restore them when switching back to grafana managed
const [prevExpressions, setPrevExpressions] = useState<AlertQuery[]>([]);
const restoreExpressionsInQueries = useCallback(() => {
addExpressionsInQueries(prevExpressions);
}, [prevExpressions, addExpressionsInQueries]);
const onClickSwitch = useCallback(() => {
const typeInForm = getValues('type');
if (typeInForm === RuleFormType.cloudAlerting) {
setValue('type', RuleFormType.grafana);
setPrevExpressions.length > 0 && restoreExpressionsInQueries();
} else {
setValue('type', RuleFormType.cloudAlerting);
const expressions = queries.filter((query) => query.datasourceUid === ExpressionDatasourceUID);
setPrevExpressions(expressions);
removeExpressionsInQueries();
}
}, [getValues, setValue, queries, removeExpressionsInQueries, restoreExpressionsInQueries, setPrevExpressions]);
return (
<RuleEditorSection stepNo={2} title="Define query and alert condition">
<AlertType editingExistingRule={editingExistingRule} />
{/* This is the cloud data source selector */}
{(type === RuleFormType.cloudRecording || type === RuleFormType.cloudAlerting) && (
<CloudDataSourceSelector onChangeCloudDatasource={onChangeCloudDatasource} />
)}
{/* This is the PromQL Editor for recording rules */}
{isRecordingRuleType && dataSourceName && (
@ -255,24 +345,33 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
{/* This is the PromQL Editor for Cloud rules */}
{isCloudAlertRuleType && dataSourceName && (
<Field error={errors.expression?.message} invalid={!!errors.expression?.message}>
<InputControl
name="expression"
render={({ field: { ref, ...field } }) => {
return (
<ExpressionEditor
{...field}
dataSourceName={dataSourceName}
showPreviewAlertsButton={!isRecordingRuleType}
/>
);
}}
control={control}
rules={{
required: { value: true, message: 'A valid expression is required' },
}}
<Stack direction="column">
<Field error={errors.expression?.message} invalid={!!errors.expression?.message}>
<InputControl
name="expression"
render={({ field: { ref, ...field } }) => {
return (
<ExpressionEditor
{...field}
dataSourceName={dataSourceName}
showPreviewAlertsButton={!isRecordingRuleType}
onChange={onChangeExpression}
/>
);
}}
control={control}
rules={{
required: { value: true, message: 'A valid expression is required' },
}}
/>
</Field>
<SmartAlertTypeDetector
editingExistingRule={editingExistingRule}
queries={queries}
rulesSourcesWithRuler={rulesSourcesWithRuler}
onClickSwitch={onClickSwitch}
/>
</Field>
</Stack>
)}
{/* This is the editor for Grafana managed rules */}
@ -319,6 +418,12 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
Add query
</Button>
</Tooltip>
<SmartAlertTypeDetector
editingExistingRule={editingExistingRule}
rulesSourcesWithRuler={rulesSourcesWithRuler}
queries={queries}
onClickSwitch={onClickSwitch}
/>
{/* Expression Queries */}
<H5>Expressions</H5>
<div className={styles.mutedText}>Manipulate data returned from queries with math and other operations</div>
@ -418,3 +523,22 @@ const getStyles = (theme: GrafanaTheme2) => ({
color: ${theme.colors.text.link};
`,
});
const useSetExpressionAndDataSource = () => {
const { setValue } = useFormContext<RuleFormValues>();
return (updatedQueries: AlertQuery[]) => {
// update data source name and expression if it's been changed in the queries from the reducer when prom or loki query
const query = updatedQueries[0];
const dataSourceSettings = getDataSourceSrv().getInstanceSettings(query.datasourceUid);
if (!dataSourceSettings) {
throw new Error('The Data source has not been defined.');
}
setValue('dataSourceName', dataSourceSettings.name);
if (isPromOrLokiQuery(query.model)) {
const expression = query.model.expr;
setValue('expression', expression);
}
};
};

View File

@ -0,0 +1,180 @@
import { css } from '@emotion/css';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { DataSourceJsonData } from '@grafana/schema';
import { Alert, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { ExpressionDatasourceUID } from 'app/features/expressions/types';
import { AccessControlAction } from 'app/types';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
import { NeedHelpInfo } from '../NeedHelpInfo';
function getAvailableRuleTypes() {
const canCreateGrafanaRules = contextSrv.hasAccess(
AccessControlAction.AlertingRuleCreate,
contextSrv.hasEditPermissionInFolders
);
const canCreateCloudRules = contextSrv.hasAccess(AccessControlAction.AlertingRuleExternalWrite, contextSrv.isEditor);
const defaultRuleType = canCreateGrafanaRules ? RuleFormType.grafana : RuleFormType.cloudAlerting;
const enabledRuleTypes: RuleFormType[] = [];
if (canCreateGrafanaRules) {
enabledRuleTypes.push(RuleFormType.grafana);
}
if (canCreateCloudRules) {
enabledRuleTypes.push(RuleFormType.cloudAlerting, RuleFormType.cloudRecording);
}
return { enabledRuleTypes, defaultRuleType };
}
const onlyOneDSInQueries = (queries: AlertQuery[]) => {
return queries.filter((q) => q.datasourceUid !== ExpressionDatasourceUID).length === 1;
};
const getCanSwitch = ({
queries,
ruleFormType,
editingExistingRule,
rulesSourcesWithRuler,
}: {
rulesSourcesWithRuler: Array<DataSourceInstanceSettings<DataSourceJsonData>>;
queries: AlertQuery[];
ruleFormType: RuleFormType | undefined;
editingExistingRule: boolean;
}) => {
// get available rule types
const availableRuleTypes = getAvailableRuleTypes();
// check if we have only one query in queries and if it's a cloud datasource
const onlyOneDS = onlyOneDSInQueries(queries);
const dataSourceIdFromQueries = queries[0]?.datasourceUid ?? '';
const isRecordingRuleType = ruleFormType === RuleFormType.cloudRecording;
//let's check if we switch to cloud type
const canSwitchToCloudRule =
!editingExistingRule &&
!isRecordingRuleType &&
onlyOneDS &&
rulesSourcesWithRuler.some((dsJsonData) => dsJsonData.uid === dataSourceIdFromQueries);
const canSwitchToGrafanaRule = !editingExistingRule && !isRecordingRuleType;
// check for enabled types
const grafanaTypeEnabled = availableRuleTypes.enabledRuleTypes.includes(RuleFormType.grafana);
const cloudTypeEnabled = availableRuleTypes.enabledRuleTypes.includes(RuleFormType.cloudAlerting);
// can we switch to the other type? (cloud or grafana)
const canSwitchFromCloudToGrafana =
ruleFormType === RuleFormType.cloudAlerting && grafanaTypeEnabled && canSwitchToGrafanaRule;
const canSwitchFromGrafanaToCloud =
ruleFormType === RuleFormType.grafana && canSwitchToCloudRule && cloudTypeEnabled && canSwitchToCloudRule;
return canSwitchFromCloudToGrafana || canSwitchFromGrafanaToCloud;
};
export interface SmartAlertTypeDetectorProps {
editingExistingRule: boolean;
rulesSourcesWithRuler: Array<DataSourceInstanceSettings<DataSourceJsonData>>;
queries: AlertQuery[];
onClickSwitch: () => void;
}
const getContentText = (ruleFormType: RuleFormType, isEditing: boolean, dataSourceName: string, canSwitch: boolean) => {
if (isEditing) {
if (ruleFormType === RuleFormType.grafana) {
return {
contentText: `Grafana-managed alert rules allow you to create alerts that can act on data from any of our supported data sources, including having multiple data sources in the same rule. You can also add expressions to transform your data and set alert conditions. Using images in alert notifications is also supported. `,
title: `This alert rule is managed by Grafana.`,
};
} else {
return {
contentText: `Data source-managed alert rules can be used for Grafana Mimir or Grafana Loki data sources which have been configured to support rule creation. The use of expressions or multiple queries is not supported.`,
title: `This alert rule is managed by the data source ${dataSourceName}.`,
};
}
}
if (canSwitch) {
if (ruleFormType === RuleFormType.cloudAlerting) {
return {
contentText:
'Data source-managed alert rules can be used for Grafana Mimir or Grafana Loki data sources which have been configured to support rule creation. The use of expressions or multiple queries is not supported.',
title: `This alert rule is managed by the data source ${dataSourceName}. If you want to use expressions or have multiple queries, switch to a Grafana-managed alert rule.`,
};
} else {
return {
contentText:
'Grafana-managed alert rules allow you to create alerts that can act on data from any of our supported data sources, including having multiple data sources in the same rule. You can also add expressions to transform your data and set alert conditions. Using images in alert notifications is also supported.',
title: `This alert rule will be managed by Grafana. The selected data source is configured to support rule creation. You can switch to data source-managed alert rule.`,
};
}
} else {
// it can be only grafana rule
return {
contentText: `Grafana-managed alert rules allow you to create alerts that can act on data from any of our supported data sources, including having multiple data sources in the same rule. You can also add expressions to transform your data and set alert conditions. Using images in alert notifications is also supported.`,
title: `Based on the selected data sources this alert rule will be managed by Grafana.`,
};
}
};
export function SmartAlertTypeDetector({
editingExistingRule,
rulesSourcesWithRuler,
queries,
onClickSwitch,
}: SmartAlertTypeDetectorProps) {
const { getValues } = useFormContext<RuleFormValues>();
const [ruleFormType, dataSourceName] = getValues(['type', 'dataSourceName']);
const styles = useStyles2(getStyles);
const canSwitch = getCanSwitch({ queries, ruleFormType, editingExistingRule, rulesSourcesWithRuler });
const typeTitle =
ruleFormType === RuleFormType.cloudAlerting ? 'Data source-managed alert rule' : 'Grafana-managed alert rule';
const switchToLabel = ruleFormType !== RuleFormType.cloudAlerting ? 'data source-managed' : 'Grafana-managed';
const content = ruleFormType
? getContentText(ruleFormType, editingExistingRule, dataSourceName ?? '', canSwitch)
: undefined;
return (
<div className={styles.alert}>
<Alert
severity="info"
title={typeTitle}
onRemove={canSwitch ? onClickSwitch : undefined}
buttonContent={`Switch to ${switchToLabel} alert rule`}
>
<Stack gap={0.5} direction="row" alignItems={'baseline'}>
<div className={styles.alertText}>{content?.title}</div>
<div className={styles.needInfo}>
<NeedHelpInfo
contentText={content?.contentText ?? ''}
externalLink={`https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/alert-rule-types/`}
linkText={`Read about alert rule types`}
title=" Alert rule types"
/>
</div>
</Stack>
</Alert>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
alertText: css`
max-width: fit-content;
flex: 1;
`,
alert: css`
margin-top: ${theme.spacing(2)};
`,
needInfo: css`
flex: 1;
max-width: fit-content;
`,
});

View File

@ -35,6 +35,8 @@ export const setDataQueries = createAction<AlertQuery[]>('setDataQueries');
export const addNewExpression = createAction<ExpressionQueryType>('addNewExpression');
export const removeExpression = createAction<string>('removeExpression');
export const removeExpressions = createAction('removeExpressions');
export const addExpressions = createAction<AlertQuery[]>('addExpressions');
export const updateExpression = createAction<ExpressionQuery>('updateExpression');
export const updateExpressionRefId = createAction<{ oldRefId: string; newRefId: string }>('updateExpressionRefId');
export const rewireExpressions = createAction<{ oldRefId: string; newRefId: string }>('rewireExpressions');
@ -111,6 +113,12 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder
.addCase(removeExpression, (state, { payload }) => {
state.queries = state.queries.filter((query) => query.refId !== payload);
})
.addCase(removeExpressions, (state) => {
state.queries = state.queries.filter((query) => !isExpressionQuery(query.model));
})
.addCase(addExpressions, (state, { payload }) => {
state.queries = [...state.queries, ...payload];
})
.addCase(updateExpression, (state, { payload }) => {
state.queries = state.queries.map((query) => {
const dataSourceAlertQuery = findDataSourceFromExpression(state.queries, payload.expression);

View File

@ -28,9 +28,6 @@ export const ui = {
save: byRole('button', { name: 'Save rule' }),
addAnnotation: byRole('button', { name: /Add info/ }),
addLabel: byRole('button', { name: /Add label/ }),
// alert type buttons
grafanaManagedAlert: byRole('button', { name: /Grafana managed/ }),
lotexAlert: byRole('button', { name: /Mimir or Loki alert/ }),
},
};