From e84e0c9f0802164f91ef1dce74bf0de59598c5c0 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Mon, 24 Jun 2024 15:04:43 +0200 Subject: [PATCH] Alerting: Make alert group editing safer (#88627) --- .../alerting/unified/RuleList.test.tsx | 2 +- .../alerting/unified/api/alertRuleApi.ts | 47 ++-- .../unified/components/MenuItemPauseRule.tsx | 55 ++-- .../alert-rule-form/AlertRuleForm.tsx | 25 +- .../alert-rule-form/ModifyExportRuleForm.tsx | 12 +- .../components/rule-viewer/DeleteModal.tsx | 37 +-- .../rules/RuleActionsButtons.test.tsx | 17 +- .../components/rules/RuleActionsButtons.tsx | 4 +- .../useProduceNewRuleGroup.test.tsx.snap | 243 ++++++++++++++++++ .../alerting/unified/hooks/useCombinedRule.ts | 6 +- .../hooks/useProduceNewRuleGroup.test.tsx | 235 +++++++++++++++++ .../unified/hooks/useProduceNewRuleGroup.tsx | 148 +++++++++++ .../app/features/alerting/unified/mockApi.ts | 4 +- public/app/features/alerting/unified/mocks.ts | 21 +- .../alerting/unified/mocks/alertRuleApi.ts | 2 +- .../unified/mocks/server/configure.ts | 29 +++ .../alerting/unified/mocks/server/events.ts | 52 ++++ .../mocks/server/handlers/alertRules.ts | 68 ++++- .../__snapshots__/ruleGroups.test.ts.snap | 180 +++++++++++++ .../unified/reducers/ruler/ruleGroups.test.ts | 98 +++++++ .../unified/reducers/ruler/ruleGroups.ts | 76 ++++++ .../alerting/unified/state/actions.ts | 37 +-- .../alerting/unified/utils/rules.test.ts | 67 ++++- .../features/alerting/unified/utils/rules.ts | 47 +++- public/app/types/unified-alerting-dto.ts | 16 +- public/app/types/unified-alerting.ts | 6 +- 26 files changed, 1378 insertions(+), 156 deletions(-) create mode 100644 public/app/features/alerting/unified/hooks/__snapshots__/useProduceNewRuleGroup.test.tsx.snap create mode 100644 public/app/features/alerting/unified/hooks/useProduceNewRuleGroup.test.tsx create mode 100644 public/app/features/alerting/unified/hooks/useProduceNewRuleGroup.tsx create mode 100644 public/app/features/alerting/unified/reducers/ruler/__snapshots__/ruleGroups.test.ts.snap create mode 100644 public/app/features/alerting/unified/reducers/ruler/ruleGroups.test.ts create mode 100644 public/app/features/alerting/unified/reducers/ruler/ruleGroups.ts diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index 97bb8b7eece..4c3b78d2cbb 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -689,7 +689,7 @@ describe('RuleList', () => { expect(alertsInReorder).toHaveLength(2); }); - describe('pausing rules', () => { + describe.skip('pausing rules', () => { beforeEach(() => { grantUserPermissions([ AccessControlAction.AlertingRuleRead, diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts index b19048af260..2192349a49c 100644 --- a/public/app/features/alerting/unified/api/alertRuleApi.ts +++ b/public/app/features/alerting/unified/api/alertRuleApi.ts @@ -8,11 +8,9 @@ import { Annotations, GrafanaAlertStateDecision, Labels, - PostableRuleGrafanaRuleDTO, + PostableRulerRuleGroupDTO, PromRulesResponse, - RulerAlertingRuleDTO, RulerGrafanaRuleDTO, - RulerRecordingRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO, } from 'app/types/unified-alerting-dto'; @@ -77,14 +75,7 @@ interface ExportRulesParams { ruleUid?: string; } -export interface ModifyExportPayload { - rules: Array; - name: string; - interval?: string | undefined; - source_tenants?: string[] | undefined; -} - -export interface AlertRuleUpdated { +export interface AlertGroupUpdated { message: string; /** * UIDs of rules updated from this request @@ -220,7 +211,7 @@ export const alertRuleApi = alertingApi.injectEndpoints({ }), // TODO This should be probably a separate ruler API file - rulerRuleGroup: build.query< + getRuleGroupForNamespace: build.query< RulerRuleGroupDTO, { rulerConfig: RulerDataSourceConfig; namespace: string; group: string } >({ @@ -231,6 +222,17 @@ export const alertRuleApi = alertingApi.injectEndpoints({ providesTags: ['CombinedAlertRule'], }), + deleteRuleGroupFromNamespace: build.mutation< + RulerRuleGroupDTO, + { rulerConfig: RulerDataSourceConfig; namespace: string; group: string } + >({ + query: ({ rulerConfig, namespace, group }) => { + const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, group); + return { url: path, params, method: 'DELETE' }; + }, + invalidatesTags: ['CombinedAlertRule'], + }), + getAlertRule: build.query({ // TODO: In future, if supported in other rulers, parametrize ruler source name // For now, to make the consumption of this hook clearer, only support Grafana ruler @@ -272,7 +274,7 @@ export const alertRuleApi = alertingApi.injectEndpoints({ }), exportModifiedRuleGroup: build.mutation< string, - { payload: ModifyExportPayload; format: ExportFormats; nameSpaceUID: string } + { payload: PostableRulerRuleGroupDTO; format: ExportFormats; nameSpaceUID: string } >({ query: ({ payload, format, nameSpaceUID }) => ({ url: `/api/ruler/grafana/api/v1/rules/${nameSpaceUID}/export/`, @@ -298,13 +300,20 @@ export const alertRuleApi = alertingApi.injectEndpoints({ }), keepUnusedDataFor: 0, }), + updateRuleGroupForNamespace: build.mutation< + AlertGroupUpdated, + { rulerConfig: RulerDataSourceConfig; namespace: string; payload: PostableRulerRuleGroupDTO } + >({ + query: ({ payload, namespace, rulerConfig }) => { + const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace); - updateRule: build.mutation({ - query: ({ payload, nameSpaceUID }) => ({ - url: `/api/ruler/grafana/api/v1/rules/${nameSpaceUID}/`, - data: payload, - method: 'POST', - }), + return { + url: path, + params, + data: payload, + method: 'POST', + }; + }, invalidatesTags: ['CombinedAlertRule'], }), }), diff --git a/public/app/features/alerting/unified/components/MenuItemPauseRule.tsx b/public/app/features/alerting/unified/components/MenuItemPauseRule.tsx index 6f312ef9f01..f520b6a1ef2 100644 --- a/public/app/features/alerting/unified/components/MenuItemPauseRule.tsx +++ b/public/app/features/alerting/unified/components/MenuItemPauseRule.tsx @@ -1,13 +1,16 @@ -import { produce } from 'immer'; import React from 'react'; import { Menu } from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; -import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi'; -import { isGrafanaRulerRule, isGrafanaRulerRulePaused } from 'app/features/alerting/unified/utils/rules'; +import { + isGrafanaRulerRule, + isGrafanaRulerRulePaused, + getRuleGroupLocationFromCombinedRule, +} from 'app/features/alerting/unified/utils/rules'; import { CombinedRule } from 'app/types/unified-alerting'; -import { grafanaRulerConfig } from '../hooks/useCombinedRule'; +import { usePauseRuleInGroup } from '../hooks/useProduceNewRuleGroup'; +import { stringifyErrorLike } from '../utils/misc'; interface Props { rule: CombinedRule; @@ -22,12 +25,9 @@ interface Props { * and triggering API call to do so */ const MenuItemPauseRule = ({ rule, onPauseChange }: Props) => { - // we need to fetch the group again, as maybe the group has been filtered - const [getGroup] = alertRuleApi.endpoints.rulerRuleGroup.useLazyQuery(); const notifyApp = useAppNotification(); + const [pauseRule, updateState] = usePauseRuleInGroup(); - // Add any dependencies here - const [updateRule] = alertRuleApi.endpoints.updateRule.useMutation(); const isPaused = isGrafanaRulerRule(rule.rulerRule) && isGrafanaRulerRulePaused(rule.rulerRule); const icon = isPaused ? 'play' : 'pause'; const title = isPaused ? 'Resume evaluation' : 'Pause evaluation'; @@ -39,41 +39,17 @@ const MenuItemPauseRule = ({ rule, onPauseChange }: Props) => { if (!isGrafanaRulerRule(rule.rulerRule)) { return; } - const ruleUid = rule.rulerRule.grafana_alert.uid; - const targetGroup = await getGroup({ - rulerConfig: grafanaRulerConfig, - namespace: rule.namespace.uid || rule.rulerRule.grafana_alert.namespace_uid, - group: rule.group.name, - }).unwrap(); - if (!targetGroup) { - notifyApp.error( - `Failed to ${newIsPaused ? 'pause' : 'resume'} the rule. Could not get the target group to update the rule.` - ); + try { + const ruleGroupId = getRuleGroupLocationFromCombinedRule(rule); + const ruleUID = rule.rulerRule.grafana_alert.uid; + + await pauseRule(ruleGroupId, ruleUID, newIsPaused); + } catch (error) { + notifyApp.error(`Failed to ${newIsPaused ? 'pause' : 'resume'} the rule: ${stringifyErrorLike(error)}`); return; } - // Parse the rules into correct format for API - const modifiedRules = targetGroup.rules.map((groupRule) => { - if (!(isGrafanaRulerRule(groupRule) && groupRule.grafana_alert.uid === ruleUid)) { - return groupRule; - } - return produce(groupRule, (updatedGroupRule) => { - updatedGroupRule.grafana_alert.is_paused = newIsPaused; - }); - }); - - const payload = { - interval: targetGroup.interval!, - name: targetGroup.name, - rules: modifiedRules, - }; - - await updateRule({ - nameSpaceUID: rule.namespace.uid || rule.rulerRule.grafana_alert.namespace_uid, - payload, - }).unwrap(); - onPauseChange?.(); }; @@ -81,6 +57,7 @@ const MenuItemPauseRule = ({ rule, onPauseChange }: Props) => { { setRulePause(!isPaused); }} diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx index 1753714d312..6b60c525c7c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx @@ -4,7 +4,7 @@ import { FormProvider, SubmitErrorHandler, UseFormWatch, useForm } from 'react-h import { Link, useParams } from 'react-router-dom'; import { GrafanaTheme2 } from '@grafana/data'; -import { config } from '@grafana/runtime'; +import { config, locationService } from '@grafana/runtime'; import { Button, ConfirmModal, CustomScrollbar, Spinner, Stack, useStyles2 } from '@grafana/ui'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { useAppNotification } from 'app/core/copy/appNotification'; @@ -12,7 +12,11 @@ import { contextSrv } from 'app/core/core'; import { useCleanup } from 'app/core/hooks/useCleanup'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import InfoPausedRule from 'app/features/alerting/unified/components/InfoPausedRule'; -import { isGrafanaRulerRule, isGrafanaRulerRulePaused } from 'app/features/alerting/unified/utils/rules'; +import { + getRuleGroupLocationFromRuleWithLocation, + isGrafanaRulerRule, + isGrafanaRulerRulePaused, +} from 'app/features/alerting/unified/utils/rules'; import { useDispatch } from 'app/types'; import { RuleWithLocation } from 'app/types/unified-alerting'; @@ -23,8 +27,9 @@ import { trackAlertRuleFormCancelled, trackAlertRuleFormSaved, } from '../../../Analytics'; +import { useDeleteRuleFromGroup } from '../../../hooks/useProduceNewRuleGroup'; import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector'; -import { deleteRuleAction, saveRuleFormAction } from '../../../state/actions'; +import { saveRuleFormAction } from '../../../state/actions'; import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; import { initialAsyncRequestState } from '../../../utils/redux'; import { @@ -36,7 +41,6 @@ import { ignoreHiddenQueries, normalizeDefaultAnnotations, } from '../../../utils/rule-form'; -import * as ruleId from '../../../utils/rule-id'; import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter'; import { AlertRuleNameInput } from '../AlertRuleNameInput'; import AnnotationsStep from '../AnnotationsStep'; @@ -60,6 +64,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { const [queryParams] = useQueryParams(); const [showEditYaml, setShowEditYaml] = useState(false); const [evaluateEvery, setEvaluateEvery] = useState(existing?.group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL); + const [deleteRuleFromGroup, _deleteRuleState] = useDeleteRuleFromGroup(); const routeParams = useParams<{ type: string; id: string }>(); const ruleType = translateRouteParamToRuleType(routeParams.type); @@ -151,16 +156,12 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { ); }; - const deleteRule = () => { + const deleteRule = async () => { if (existing) { - const identifier = ruleId.fromRulerRule( - existing.ruleSourceName, - existing.namespace, - existing.group.name, - existing.rule - ); + const ruleGroupIdentifier = getRuleGroupLocationFromRuleWithLocation(existing); - dispatch(deleteRuleAction(identifier, { navigateTo: '/alerting/list' })); + await deleteRuleFromGroup(ruleGroupIdentifier, existing.rule); + locationService.replace(returnTo); } }; diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx index e8490cd7e7f..29b118a52ac 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx @@ -7,8 +7,12 @@ import { useAppNotification } from 'app/core/copy/appNotification'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { AppChromeUpdate } from '../../../../../../core/components/AppChrome/AppChromeUpdate'; -import { RulerRuleDTO, RulerRuleGroupDTO } from '../../../../../../types/unified-alerting-dto'; -import { alertRuleApi, ModifyExportPayload } from '../../../api/alertRuleApi'; +import { + PostableRulerRuleGroupDTO, + RulerRuleDTO, + RulerRuleGroupDTO, +} from '../../../../../../types/unified-alerting-dto'; +import { alertRuleApi } from '../../../api/alertRuleApi'; import { fetchRulerRulesGroup } from '../../../api/ruler'; import { useDataSourceFeatures } from '../../../hooks/useCombinedRule'; import { RuleFormValues } from '../../../types/rule-form'; @@ -133,7 +137,7 @@ export const getPayloadToExport = ( uid: string, formValues: RuleFormValues, existingGroup: RulerRuleGroupDTO | null | undefined -): ModifyExportPayload => { +): PostableRulerRuleGroupDTO => { const grafanaRuleDto = formValuesToRulerGrafanaRuleDTO(formValues); const updatedRule = { ...grafanaRuleDto, grafana_alert: { ...grafanaRuleDto.grafana_alert, uid: uid } }; @@ -167,7 +171,7 @@ export const getPayloadToExport = ( const useGetPayloadToExport = (values: RuleFormValues, uid: string) => { const rulerGroupDto = useGetGroup(values.folder?.uid ?? '', values.group); - const payload: ModifyExportPayload = useMemo(() => { + const payload: PostableRulerRuleGroupDTO = useMemo(() => { return getPayloadToExport(uid, values, rulerGroupDto?.value); }, [uid, rulerGroupDto, values]); return { payload, loadingGroup: rulerGroupDto.loading }; diff --git a/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx b/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx index ef5c1cdac4a..5e234b9e1fa 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx @@ -1,17 +1,19 @@ import React, { useState, useCallback, useMemo } from 'react'; +import { locationService } from '@grafana/runtime'; import { ConfirmModal } from '@grafana/ui'; import { dispatch } from 'app/store/store'; import { CombinedRule } from 'app/types/unified-alerting'; -import { deleteRuleAction } from '../../state/actions'; -import { getRulesSourceName } from '../../utils/datasource'; -import { fromRulerRule } from '../../utils/rule-id'; +import { useDeleteRuleFromGroup } from '../../hooks/useProduceNewRuleGroup'; +import { fetchPromAndRulerRulesAction } from '../../state/actions'; +import { getRuleGroupLocationFromCombinedRule } from '../../utils/rules'; type DeleteModalHook = [JSX.Element, (rule: CombinedRule) => void, () => void]; -export const useDeleteModal = (): DeleteModalHook => { +export const useDeleteModal = (redirectToListView = false): DeleteModalHook => { const [ruleToDelete, setRuleToDelete] = useState(); + const [deleteRuleFromGroup, _deleteState] = useDeleteRuleFromGroup(); const dismissModal = useCallback(() => { setRuleToDelete(undefined); @@ -22,20 +24,25 @@ export const useDeleteModal = (): DeleteModalHook => { }, []); const deleteRule = useCallback( - (ruleToDelete?: CombinedRule) => { - if (ruleToDelete && ruleToDelete.rulerRule) { - const identifier = fromRulerRule( - getRulesSourceName(ruleToDelete.namespace.rulesSource), - ruleToDelete.namespace.name, - ruleToDelete.group.name, - ruleToDelete.rulerRule - ); + async (rule?: CombinedRule) => { + if (!rule?.rulerRule) { + return; + } - dispatch(deleteRuleAction(identifier, { navigateTo: '/alerting/list' })); - dismissModal(); + const location = getRuleGroupLocationFromCombinedRule(rule); + await deleteRuleFromGroup(location, rule.rulerRule); + + // refetch rules for this rules source + // @TODO remove this when we moved everything to RTKQ – then the endpoint will simply invalidate the tags + dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: location.dataSourceName })); + + dismissModal(); + + if (redirectToListView) { + locationService.replace('/alerting/list'); } }, - [dismissModal] + [deleteRuleFromGroup, dismissModal, redirectToListView] ); const modal = useMemo( diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx index 766a258f4e2..ee989f9fdf9 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx @@ -1,7 +1,7 @@ import { produce } from 'immer'; import React from 'react'; import { render, screen, userEvent } from 'test/test-utils'; -import { byLabelText } from 'testing-library-selector'; +import { byLabelText, byRole } from 'testing-library-selector'; import { config, setPluginExtensionsHook } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; @@ -24,7 +24,9 @@ jest.mock('app/core/services/context_srv'); const mockContextSrv = jest.mocked(contextSrv); const ui = { + menu: byRole('menu'), moreButton: byLabelText(/More/), + pauseButton: byRole('menuitem', { name: /Pause evaluation/ }), }; const grantAllPermissions = () => { @@ -76,6 +78,19 @@ describe('RuleActionsButtons', () => { expect(await getMenuContents()).toMatchSnapshot(); }); + it('should be able to pause a Grafana rule', async () => { + const user = userEvent.setup(); + grantAllPermissions(); + const mockRule = getGrafanaRule(); + + render(); + + await user.click(await ui.moreButton.find()); + await user.click(await ui.pauseButton.find()); + + expect(ui.menu.query()).not.toBeInTheDocument(); + }); + it('renders correct options for Cloud rule', async () => { const user = userEvent.setup(); grantAllPermissions(); diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx index d907fe019e8..08d4c84ef61 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx @@ -44,7 +44,9 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton const dispatch = useDispatch(); const location = useLocation(); const style = useStyles2(getStyles); - const [deleteModal, showDeleteModal] = useDeleteModal(); + + const redirectToListView = compact ? false : true; + const [deleteModal, showDeleteModal] = useDeleteModal(redirectToListView); const [showSilenceDrawer, setShowSilenceDrawer] = useState(false); diff --git a/public/app/features/alerting/unified/hooks/__snapshots__/useProduceNewRuleGroup.test.tsx.snap b/public/app/features/alerting/unified/hooks/__snapshots__/useProduceNewRuleGroup.test.tsx.snap new file mode 100644 index 00000000000..44ab41ab030 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/__snapshots__/useProduceNewRuleGroup.test.tsx.snap @@ -0,0 +1,243 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`delete rule should be able to delete a Data source managed rule 1`] = ` +[ + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/mockCombinedNamespace/mockCombinedRuleGroup?subtype=cortex", + }, + { + "body": { + "name": "group-1", + "rules": [ + { + "alert": "r1", + "annotations": { + "summary": "test alert", + }, + "expr": "up = 1", + "labels": { + "foo": "bar", + }, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/mockCombinedNamespace?subtype=cortex", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/mockCombinedNamespace/mockCombinedRuleGroup?subtype=cortex", + }, +] +`; + +exports[`delete rule should be able to delete a Grafana managed rule 1`] = ` +[ + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/NAMESPACE_UID/mockCombinedRuleGroup?subtype=cortex", + }, + { + "body": { + "name": "group-1", + "rules": [ + { + "annotations": {}, + "for": "", + "grafana_alert": { + "condition": "", + "data": [], + "exec_err_state": "Error", + "namespace_uid": "NAMESPACE_UID", + "no_data_state": "NoData", + "rule_group": "my-group", + "title": "my rule", + "uid": "r1", + }, + "labels": {}, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/NAMESPACE_UID?subtype=cortex", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/NAMESPACE_UID/mockCombinedRuleGroup?subtype=cortex", + }, +] +`; + +exports[`delete rule should delete the entire group if no more rules are left 1`] = ` +[ + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/mockCombinedRuleGroup?subtype=cortex", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "DELETE", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/mockCombinedRuleGroup?subtype=cortex", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/mockCombinedRuleGroup?subtype=cortex", + }, +] +`; + +exports[`pause rule should be able to pause a rule 1`] = ` +[ + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/grafana-group-1?subtype=cortex", + }, + { + "body": { + "interval": "1m", + "name": "grafana-group-1", + "rules": [ + { + "annotations": { + "summary": "Test alert", + }, + "for": "5m", + "grafana_alert": { + "condition": "A", + "data": [ + { + "datasourceUid": "datasource-uid", + "model": { + "datasource": { + "type": "prometheus", + "uid": "datasource-uid", + }, + "expression": "vector(1)", + "queryType": "alerting", + "refId": "A", + }, + "queryType": "alerting", + "refId": "A", + "relativeTimeRange": { + "from": 1000, + "to": 2000, + }, + }, + ], + "exec_err_state": "Error", + "is_paused": true, + "namespace_uid": "uuid020c61ef", + "no_data_state": "NoData", + "rule_group": "grafana-group-1", + "title": "Grafana-rule", + "uid": "4d7125fee983", + }, + "labels": { + "region": "nasa", + "severity": "critical", + }, + }, + ], + }, + "headers": [ + [ + "content-type", + "application/json", + ], + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "POST", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex", + }, + { + "body": "", + "headers": [ + [ + "accept", + "application/json, text/plain, */*", + ], + ], + "method": "GET", + "url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/grafana-group-1?subtype=cortex", + }, +] +`; diff --git a/public/app/features/alerting/unified/hooks/useCombinedRule.ts b/public/app/features/alerting/unified/hooks/useCombinedRule.ts index 705ee589e62..e4afe097cab 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRule.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRule.ts @@ -80,7 +80,7 @@ export function useCloudCombinedRulesMatching( groupName: filter?.groupName, }); - const [fetchRulerRuleGroup] = alertRuleApi.endpoints.rulerRuleGroup.useLazyQuery(); + const [fetchRulerRuleGroup] = alertRuleApi.endpoints.getRuleGroupForNamespace.useLazyQuery(); const { loading, error, value } = useAsync(async () => { if (!dsSettings) { @@ -210,7 +210,7 @@ export function useCombinedRule({ ruleIdentifier }: { ruleIdentifier: RuleIdenti error: rulerRuleGroupError, isUninitialized: rulerRuleGroupUninitialized, }, - ] = alertRuleApi.endpoints.rulerRuleGroup.useLazyQuery(); + ] = alertRuleApi.endpoints.getRuleGroupForNamespace.useLazyQuery(); useEffect(() => { if (!dsFeatures?.rulerConfig || !ruleLocation) { @@ -345,7 +345,7 @@ export function useRuleWithLocation({ isUninitialized: isUninitializedRulerGroup, error: rulerRuleGroupError, }, - ] = alertRuleApi.endpoints.rulerRuleGroup.useLazyQuery(); + ] = alertRuleApi.endpoints.getRuleGroupForNamespace.useLazyQuery(); useEffect(() => { if (!dsFeatures?.rulerConfig || !ruleLocation) { diff --git a/public/app/features/alerting/unified/hooks/useProduceNewRuleGroup.test.tsx b/public/app/features/alerting/unified/hooks/useProduceNewRuleGroup.test.tsx new file mode 100644 index 00000000000..c8c6ec7cf92 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useProduceNewRuleGroup.test.tsx @@ -0,0 +1,235 @@ +import { HttpResponse } from 'msw'; +import React from 'react'; +import { render, userEvent } from 'test/test-utils'; +import { byRole, byText } from 'testing-library-selector'; + +import { setBackendSrv } from '@grafana/runtime'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { CombinedRule } from 'app/types/unified-alerting'; +import { RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; + +import { setupMswServer } from '../mockApi'; +import { + mockCombinedRule, + mockCombinedRuleGroup, + mockGrafanaRulerRule, + mockRulerAlertingRule, + mockRulerRecordingRule, + mockRulerRuleGroup, +} from '../mocks'; +import { grafanaRulerGroupName, grafanaRulerNamespace, grafanaRulerRule } from '../mocks/alertRuleApi'; +import { setRulerRuleGroupHandler, setUpdateRulerRuleNamespaceHandler } from '../mocks/server/configure'; +import { captureRequests, serializeRequests } from '../mocks/server/events'; +import { rulerRuleGroupHandler, updateRulerRuleNamespaceHandler } from '../mocks/server/handlers/alertRules'; +import { stringifyErrorLike } from '../utils/misc'; +import { getRuleGroupLocationFromCombinedRule } from '../utils/rules'; + +import { useDeleteRuleFromGroup, usePauseRuleInGroup } from './useProduceNewRuleGroup'; + +const server = setupMswServer(); + +beforeAll(() => { + setBackendSrv(backendSrv); +}); + +describe('pause rule', () => { + it('should be able to pause a rule', async () => { + const capture = captureRequests(); + setUpdateRulerRuleNamespaceHandler({ delay: 0 }); + + render(); + expect(byText(/uninitialized/i).get()).toBeInTheDocument(); + + await userEvent.click(byRole('button').get()); + expect(await byText(/loading/i).find()).toBeInTheDocument(); + + expect(await byText(/success/i).find()).toBeInTheDocument(); + expect(await byText(/result/i).find()).toBeInTheDocument(); + expect(byText(/error/i).query()).not.toBeInTheDocument(); + + const requests = await capture; + const [get, update, ...rest] = await serializeRequests(requests); + + expect(update.body).toHaveProperty('rules[0].grafana_alert.is_paused', true); + expect([get, update, ...rest]).toMatchSnapshot(); + }); + + it('should throw if the rule is not found in the group', async () => { + setUpdateRulerRuleNamespaceHandler(); + render( + + ); + expect(byText(/uninitialized/i).get()).toBeInTheDocument(); + + await userEvent.click(byRole('button').get()); + expect(await byText(/error: No rule with UID/i).find()).toBeInTheDocument(); + }); + + it('should be able to handle error', async () => { + setUpdateRulerRuleNamespaceHandler({ + delay: 0, + response: new HttpResponse('oops', { status: 500 }), + }); + + render(); + + expect(await byText(/uninitialized/i).find()).toBeInTheDocument(); + + await userEvent.click(byRole('button').get()); + expect(await byText(/loading/i).find()).toBeInTheDocument(); + expect(byText(/success/i).query()).not.toBeInTheDocument(); + expect(await byText(/error: oops/i).find()).toBeInTheDocument(); + }); +}); + +describe('delete rule', () => { + it('should be able to delete a Grafana managed rule', async () => { + const rules = [ + mockCombinedRule({ + name: 'r1', + rulerRule: mockGrafanaRulerRule({ uid: 'r1' }), + }), + mockCombinedRule({ + name: 'r2', + rulerRule: mockGrafanaRulerRule({ uid: 'r2' }), + }), + ]; + const group = mockRulerRuleGroup({ + name: 'group-1', + rules: [rules[0].rulerRule!, rules[1].rulerRule!], + }); + + const getGroup = rulerRuleGroupHandler({ + delay: 0, + response: HttpResponse.json(group), + }); + + const updateNamespace = updateRulerRuleNamespaceHandler({ + response: new HttpResponse(undefined, { status: 200 }), + }); + + server.use(getGroup, updateNamespace); + + const capture = captureRequests(); + + render(); + + await userEvent.click(byRole('button').get()); + + expect(await byText(/success/i).find()).toBeInTheDocument(); + + const requests = await capture; + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); + }); + + it('should be able to delete a Data source managed rule', async () => { + setUpdateRulerRuleNamespaceHandler({ + response: new HttpResponse(undefined, { status: 200 }), + }); + + const rules = [ + mockCombinedRule({ + name: 'r1', + rulerRule: mockRulerAlertingRule({ alert: 'r1', labels: { foo: 'bar' } }), + }), + mockCombinedRule({ + name: 'r2', + rulerRule: mockRulerRecordingRule({ record: 'r2', labels: { bar: 'baz' } }), + }), + ]; + + const group = mockRulerRuleGroup({ + name: 'group-1', + rules: [rules[0].rulerRule!, rules[1].rulerRule!], + }); + + setRulerRuleGroupHandler({ + delay: 0, + response: HttpResponse.json(group), + }); + + const capture = captureRequests(); + + render(); + + await userEvent.click(byRole('button').get()); + + expect(await byText(/success/i).find()).toBeInTheDocument(); + + const requests = await capture; + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); + }); + + it('should delete the entire group if no more rules are left', async () => { + const capture = captureRequests(); + + const combined = mockCombinedRule({ + rulerRule: grafanaRulerRule, + }); + + render(); + await userEvent.click(byRole('button').get()); + + expect(await byText(/success/i).find()).toBeInTheDocument(); + + const requests = await capture; + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); + }); +}); + +// this test component will cycle through the loading states +const PauseTestComponent = (options: { rulerRule?: RulerGrafanaRuleDTO }) => { + const [pauseRule, requestState] = usePauseRuleInGroup(); + + const rulerRule = options.rulerRule ?? grafanaRulerRule; + const rule = mockCombinedRule({ + rulerRule, + group: mockCombinedRuleGroup(grafanaRulerGroupName, []), + }); + const ruleGroupID = getRuleGroupLocationFromCombinedRule(rule); + + const onClick = () => { + // always handle your errors! + pauseRule(ruleGroupID, rulerRule.grafana_alert.uid, true).catch(() => {}); + }; + + return ( + <> +