diff --git a/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx b/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx index 01c66b8cf27..b1a2a6ea4fc 100644 --- a/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx @@ -36,6 +36,75 @@ jest.mock('app/features/query/components/QueryEditorRow', () => ({ QueryEditorRow: () =>

hi

, })); +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(); diff --git a/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx b/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx index 0ed3e4bf9bc..228a0980ade 100644 --- a/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx @@ -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: () =>

hi

, })); +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 }) =>
{actions}
, })); @@ -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)); diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/AlertType.test.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/AlertType.test.tsx deleted file mode 100644 index 14041e827bb..00000000000 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/AlertType.test.tsx +++ /dev/null @@ -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 {children}; -}; - -function renderAlertTypeStep() { - const store = configureStore(); - - render( - - - - - , - { 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(); - }); - }); -}); diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/AlertType.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/CloudDataSourceSelector.tsx similarity index 55% rename from public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/AlertType.tsx rename to public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/CloudDataSourceSelector.tsx index a11a2884077..c8140a5eec1 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/AlertType.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/CloudDataSourceSelector.tsx @@ -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(); @@ -31,26 +24,6 @@ export const AlertType = ({ editingExistingRule }: Props) => { return ( <> - {!editingExistingRule && ruleFormType !== RuleFormType.cloudRecording && ( - - ( - - )} - name="type" - control={control} - rules={{ - required: { value: true, message: 'Please select alert type' }, - }} - /> - - )} -
{(ruleFormType === RuleFormType.cloudAlerting || ruleFormType === RuleFormType.cloudRecording) && ( { // 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; 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 index 6d83fa64b36..1118e04c4fb 100644 --- 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 @@ -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([]); + + 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 ( - + {/* This is the cloud data source selector */} + {(type === RuleFormType.cloudRecording || type === RuleFormType.cloudAlerting) && ( + + )} {/* 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 && ( - - { - return ( - - ); - }} - control={control} - rules={{ - required: { value: true, message: 'A valid expression is required' }, - }} + + + { + return ( + + ); + }} + control={control} + rules={{ + required: { value: true, message: 'A valid expression is required' }, + }} + /> + + - + )} {/* This is the editor for Grafana managed rules */} @@ -319,6 +418,12 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P Add query + {/* Expression Queries */}
Expressions
Manipulate data returned from queries with math and other operations
@@ -418,3 +523,22 @@ const getStyles = (theme: GrafanaTheme2) => ({ color: ${theme.colors.text.link}; `, }); + +const useSetExpressionAndDataSource = () => { + const { setValue } = useFormContext(); + 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); + } + }; +}; diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/SmartAlertTypeDetector.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/SmartAlertTypeDetector.tsx new file mode 100644 index 00000000000..067939436ee --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/SmartAlertTypeDetector.tsx @@ -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>; + 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>; + 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(); + + 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 ( +
+ + +
{content?.title}
+
+ +
+
+
+
+ ); +} + +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; + `, +}); 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 index 45e9d0861cf..bead457326e 100644 --- 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 @@ -35,6 +35,8 @@ export const setDataQueries = createAction('setDataQueries'); export const addNewExpression = createAction('addNewExpression'); export const removeExpression = createAction('removeExpression'); +export const removeExpressions = createAction('removeExpressions'); +export const addExpressions = createAction('addExpressions'); export const updateExpression = createAction('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); diff --git a/public/test/helpers/alertingRuleEditor.tsx b/public/test/helpers/alertingRuleEditor.tsx index eb099bad304..544398579cc 100644 --- a/public/test/helpers/alertingRuleEditor.tsx +++ b/public/test/helpers/alertingRuleEditor.tsx @@ -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/ }), }, };