From 277d2714765cf10b64d8e887174d56285018d8c8 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Wed, 7 May 2025 11:25:31 +0200 Subject: [PATCH] Alerting: Add delete bulk action in the alert list view (#104611) * Add pause/unpause bulk actions button in the alert list view * add delete bulk action * Add tracking and refactor FolderActionMenuItem * update translations and text * update text * add finally * use ability for delete action * don't show bulk actions if no action is allowed * invalidate tags for delete action * revert invalidating and redirect to list page instead * redirect when pausing/unpausing * add pause/unpause endpoints * add translations * add folder name in delete modal * address pr review * disable pause/unpause * update redirect * remove unnecessary prop * rename FolderActionMenuItem component to PauseUnpauseActionMenuItem * address review comments * fetch rules before redirecting in list view 1 --------- Co-authored-by: Gilles De Mey Co-authored-by: Mariell Hoversholm --- .../features/alerting/unified/Analytics.ts | 24 ++++ .../unified/api/alertingFolderActionsApi.ts | 70 +++++++++++ .../unified/components/MoreButton.tsx | 9 +- .../folder-bulk-actions/DeleteModal.tsx | 55 +++++++++ .../FolderBulkActionsButton.tsx | 115 ++++++++++++++++++ .../PauseUnpauseActionMenuItem.tsx | 43 +++++++ .../unified/components/rules/RulesGroup.tsx | 7 ++ .../alerting/unified/hooks/useAbilities.ts | 23 +++- .../rule-list/PaginatedGrafanaLoader.tsx | 5 + public/locales/en-US/grafana.json | 38 ++++++ 10 files changed, 385 insertions(+), 4 deletions(-) create mode 100644 public/app/features/alerting/unified/api/alertingFolderActionsApi.ts create mode 100644 public/app/features/alerting/unified/components/folder-bulk-actions/DeleteModal.tsx create mode 100644 public/app/features/alerting/unified/components/folder-bulk-actions/FolderBulkActionsButton.tsx create mode 100644 public/app/features/alerting/unified/components/folder-bulk-actions/PauseUnpauseActionMenuItem.tsx diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts index ee7ba077400..c531794482a 100644 --- a/public/app/features/alerting/unified/Analytics.ts +++ b/public/app/features/alerting/unified/Analytics.ts @@ -299,6 +299,30 @@ export function trackUseCentralHistoryMaxEventsReached(payload: { from: number; reportInteraction('grafana_alerting_central_alert_state_history_max_events_reached', payload); } +export function trackFolderBulkActionsDeleteSuccess() { + reportInteraction('grafana_alerting_folder_bulk_actions_delete_success'); +} + +export function trackFolderBulkActionsDeleteFail() { + reportInteraction('grafana_alerting_folder_bulk_actions_delete_fail'); +} + +export function trackFolderBulkActionsPauseSuccess() { + reportInteraction('grafana_alerting_folder_bulk_actions_pause_success'); +} + +export function trackFolderBulkActionsUnpauseSuccess() { + reportInteraction('grafana_alerting_folder_bulk_actions_unpause_success'); +} + +export function trackFolderBulkActionsPauseFail() { + reportInteraction('grafana_alerting_folder_bulk_actions_pause_fail'); +} + +export function trackFolderBulkActionsUnpauseFail() { + reportInteraction('grafana_alerting_folder_bulk_actions_unpause_fail'); +} + export type AlertRuleTrackingProps = { user_id: number; grafana_version?: string; diff --git a/public/app/features/alerting/unified/api/alertingFolderActionsApi.ts b/public/app/features/alerting/unified/api/alertingFolderActionsApi.ts new file mode 100644 index 00000000000..2a74f73f8e6 --- /dev/null +++ b/public/app/features/alerting/unified/api/alertingFolderActionsApi.ts @@ -0,0 +1,70 @@ +import { t } from 'app/core/internationalization'; + +import { WithNotificationOptions, alertingApi } from './alertingApi'; +import { GRAFANA_RULER_CONFIG } from './featureDiscoveryApi'; +import { rulerUrlBuilder } from './ruler'; + +export const alertingFolderActionsApi = alertingApi.injectEndpoints({ + endpoints: (build) => ({ + pauseFolder: build.mutation>({ + query: ({ namespace, notificationOptions }) => { + const successMessage = t( + 'alerting.bulk-actions.pause.success', + 'Rules evaluation successfully paused for folder' + ); + const { path, params } = rulerUrlBuilder(GRAFANA_RULER_CONFIG).namespace(namespace); + + return { + url: path, + params, + body: { + is_paused: true, + }, + method: 'PATCH', + notificationOptions: { + successMessage, + ...notificationOptions, + }, + }; + }, + }), + unpauseFolder: build.mutation>({ + query: ({ namespace, notificationOptions }) => { + const successMessage = t( + 'alerting.bulk-actions.unpause.success', + 'Rules successfully unpaused for this folder' + ); + const { path, params } = rulerUrlBuilder(GRAFANA_RULER_CONFIG).namespace(namespace); + + return { + url: path, + params, + body: { + is_paused: false, + }, + method: 'PATCH', + notificationOptions: { + successMessage, + ...notificationOptions, + }, + }; + }, + }), + deleteGrafanaRulesFromFolder: build.mutation>({ + query: ({ namespace, notificationOptions }) => { + const successMessage = t('alerting.bulk-actions.delete.success', 'Rules successfully deleted from folder'); + const { path, params } = rulerUrlBuilder(GRAFANA_RULER_CONFIG).namespace(namespace); + + return { + url: path, + params, + method: 'DELETE', + notificationOptions: { + successMessage, + ...notificationOptions, + }, + }; + }, + }), + }), +}); diff --git a/public/app/features/alerting/unified/components/MoreButton.tsx b/public/app/features/alerting/unified/components/MoreButton.tsx index c1e3a84500e..8df141ef39d 100644 --- a/public/app/features/alerting/unified/components/MoreButton.tsx +++ b/public/app/features/alerting/unified/components/MoreButton.tsx @@ -4,18 +4,21 @@ import { Button, ButtonProps, Icon, Stack } from '@grafana/ui'; import { Trans, t } from '../../../../core/internationalization'; -const MoreButton = forwardRef(function MoreButton(props: ButtonProps, ref: Ref) { +const MoreButton = forwardRef(function MoreButton( + props: ButtonProps & { title?: string }, + ref: Ref +) { return ( diff --git a/public/app/features/alerting/unified/components/folder-bulk-actions/DeleteModal.tsx b/public/app/features/alerting/unified/components/folder-bulk-actions/DeleteModal.tsx new file mode 100644 index 00000000000..57100cced2a --- /dev/null +++ b/public/app/features/alerting/unified/components/folder-bulk-actions/DeleteModal.tsx @@ -0,0 +1,55 @@ +import React, { useState } from 'react'; + +import { ConfirmModal, Space, Text } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; + +import { trackFolderBulkActionsDeleteFail, trackFolderBulkActionsDeleteSuccess } from '../../Analytics'; + +export interface Props { + isOpen: boolean; + onConfirm: () => Promise; + onDismiss: () => void; + folderName: string; +} + +export const DeleteModal = React.memo(({ onConfirm, onDismiss, isOpen, folderName }: Props) => { + const [isDeleting, setIsDeleting] = useState(false); + const onDeleteConfirm = async () => { + setIsDeleting(true); + try { + await onConfirm(); + trackFolderBulkActionsDeleteSuccess(); + onDismiss(); + } catch { + trackFolderBulkActionsDeleteFail(); + } finally { + setIsDeleting(false); + } + }; + + return ( + + + + This action will delete all alert rules in the {'{{folderName}}'} folder. Nested folders will + not be affected. + + + + + } + confirmationText={t('alerting.folder-bulk-actions.delete-modal-confirmation-text', 'Delete')} + confirmText={ + isDeleting + ? t('alerting.folder-bulk-actions.delete-modal-deleting', 'Deleting...') + : t('alerting.folder-bulk-actions.delete-modal-delete-button', 'Delete') + } + onDismiss={onDismiss} + onConfirm={onDeleteConfirm} + title={t('alerting.folder-bulk-actions.delete-modal-title', 'Delete')} + isOpen={isOpen} + /> + ); +}); diff --git a/public/app/features/alerting/unified/components/folder-bulk-actions/FolderBulkActionsButton.tsx b/public/app/features/alerting/unified/components/folder-bulk-actions/FolderBulkActionsButton.tsx new file mode 100644 index 00000000000..728c1cb4170 --- /dev/null +++ b/public/app/features/alerting/unified/components/folder-bulk-actions/FolderBulkActionsButton.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react'; + +import { config, locationService } from '@grafana/runtime'; +import { Dropdown, IconButton, Menu } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; +import { useDispatch } from 'app/types'; + +import { alertingFolderActionsApi } from '../../api/alertingFolderActionsApi'; +import { shouldUsePrometheusRulesPrimary } from '../../featureToggles'; +import { FolderBulkAction, useFolderBulkActionAbility } from '../../hooks/useAbilities'; +import { useFolder } from '../../hooks/useFolder'; +import { fetchAllPromAndRulerRulesAction, fetchAllPromRulesAction, fetchRulerRulesAction } from '../../state/actions'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { createRelativeUrl } from '../../utils/url'; + +import { DeleteModal } from './DeleteModal'; +import { PauseUnpauseActionMenuItem } from './PauseUnpauseActionMenuItem'; +interface Props { + folderUID: string; +} + +export const FolderBulkActionsButton = ({ folderUID }: Props) => { + const [pauseSupported, pauseAllowed] = useFolderBulkActionAbility(FolderBulkAction.Pause); + const canPause = pauseSupported && pauseAllowed && false; // lets disable pause for now + const [deleteSupported, deleteAllowed] = useFolderBulkActionAbility(FolderBulkAction.Delete); + const canDelete = deleteSupported && deleteAllowed; + const [pauseFolder, updateState] = alertingFolderActionsApi.endpoints.pauseFolder.useMutation(); + const [unpauseFolder, unpauseState] = alertingFolderActionsApi.endpoints.unpauseFolder.useMutation(); + const [deleteGrafanaRulesFromFolder, deleteState] = + alertingFolderActionsApi.endpoints.deleteGrafanaRulesFromFolder.useMutation(); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const folderName = useFolder(folderUID).folder?.title || 'unknown folder'; + const listView2Enabled = config.featureToggles.alertingListViewV2 ?? false; + const view = listView2Enabled ? 'list' : 'grouped'; + const redirectToListView = useRedirectToListView(view); + + if (!canPause && !canDelete) { + return null; + } + + const onConfirmDelete = async () => { + await deleteGrafanaRulesFromFolder({ namespace: folderUID }).unwrap(); + await redirectToListView(); + }; + + const menuItems = ( + <> + {canPause && ( + <> + { + await pauseFolder({ namespace: folderUID }).unwrap(); + await redirectToListView(); + }} + isLoading={updateState.isLoading} + /> + { + await unpauseFolder({ namespace: folderUID }).unwrap(); + await redirectToListView(); + }} + isLoading={unpauseState.isLoading} + /> + + )} + {canDelete && ( + setIsDeleteModalOpen(true)} + disabled={deleteState.isLoading} + /> + )} + + ); + + return ( + <> + {menuItems}}> + + + setIsDeleteModalOpen(false)} + folderName={folderName} + /> + + ); +}; + +function useRedirectToListView(view: string) { + const dispatch = useDispatch(); + const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary(); + const redirectToListView = async () => { + if (prometheusRulesPrimary) { + await dispatch(fetchRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME })); + await dispatch(fetchAllPromRulesAction(false)); + } else { + await dispatch(fetchAllPromAndRulerRulesAction(false)); + } + locationService.push(createRelativeUrl('/alerting/list', { view }, { skipSubPath: true })); + }; + + return redirectToListView; +} diff --git a/public/app/features/alerting/unified/components/folder-bulk-actions/PauseUnpauseActionMenuItem.tsx b/public/app/features/alerting/unified/components/folder-bulk-actions/PauseUnpauseActionMenuItem.tsx new file mode 100644 index 00000000000..70cc5245ef8 --- /dev/null +++ b/public/app/features/alerting/unified/components/folder-bulk-actions/PauseUnpauseActionMenuItem.tsx @@ -0,0 +1,43 @@ +import { Menu } from '@grafana/ui'; +import { useAppNotification } from 'app/core/copy/appNotification'; +import { t } from 'app/core/internationalization'; + +import { + trackFolderBulkActionsPauseFail, + trackFolderBulkActionsPauseSuccess, + trackFolderBulkActionsUnpauseFail, + trackFolderBulkActionsUnpauseSuccess, +} from '../../Analytics'; +import { stringifyErrorLike } from '../../utils/misc'; +interface Props { + folderUID: string; + action: 'pause' | 'unpause'; + executeAction: (folderUID: string) => Promise; + isLoading: boolean; +} +export function PauseUnpauseActionMenuItem({ folderUID, executeAction, isLoading, action }: Props) { + const notifyApp = useAppNotification(); + const label = + action === 'pause' + ? t('alerting.folder-bulk-actions.pause.button.label', 'Pause all rule evaluation') + : t('alerting.folder-bulk-actions.unpause.button.label', 'Resume all rule evaluation'); + const icon = action === 'pause' ? 'pause' : 'play'; + const trackActionSuccess = + action === 'pause' ? trackFolderBulkActionsPauseSuccess : trackFolderBulkActionsUnpauseSuccess; + const trackActionFail = action === 'pause' ? trackFolderBulkActionsPauseFail : trackFolderBulkActionsUnpauseFail; + const onActionClick = async () => { + try { + await executeAction(folderUID); + trackActionSuccess(); + } catch (error) { + trackActionFail(); + notifyApp.error( + t('alerting.folder-bulk-actions.error', 'Failed to execute action for folder: {{error}}', { + error: stringifyErrorLike(error), + }) + ); + } + }; + + return ; +} diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx index dcb9bc44cb3..947ce6cb257 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; import { Badge, Icon, Spinner, Stack, Tooltip, useStyles2 } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; import { CombinedRuleGroup, CombinedRuleNamespace, RulesSource } from 'app/types/unified-alerting'; @@ -18,6 +19,7 @@ import { CollapseToggle } from '../CollapseToggle'; import { RuleLocation } from '../RuleLocation'; import { GrafanaRuleFolderExporter } from '../export/GrafanaRuleFolderExporter'; import { decodeGrafanaNamespace } from '../expressions/util'; +import { FolderBulkActionsButton } from '../folder-bulk-actions/FolderBulkActionsButton'; import { ActionIcon } from './ActionIcon'; import { RuleGroupStats } from './RuleStats'; @@ -67,6 +69,8 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: const canEditGroup = hasRuler && !isProvisioned && !isFederated && !isPluginProvided && canEditRules(rulesSourceName); + const isFolderBulkActionsEnabled = config.featureToggles.alertingBulkActionsInUI; + // check what view mode we are in const isListView = viewMode === 'list'; const isGroupView = viewMode === 'grouped'; @@ -145,6 +149,9 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: onClick={() => setIsExporting('folder')} /> ); + if (isFolderBulkActionsEnabled && folderUID && isListView) { + actionIcons.push(); + } } } } diff --git a/public/app/features/alerting/unified/hooks/useAbilities.ts b/public/app/features/alerting/unified/hooks/useAbilities.ts index db00603970f..035db07a45a 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.ts +++ b/public/app/features/alerting/unified/hooks/useAbilities.ts @@ -91,6 +91,12 @@ export enum AlertRuleAction { DeletePermanently = 'delete-alert-rule-permanently', } +// this enum list all of the bulk actions we can perform on a folder +export enum FolderBulkAction { + Pause = 'pause-folder', // unpause permissions are the same as pause + Delete = 'delete-folder', +} + // this enum lists all of the actions we can perform within alerting in general, not linked to a specific // alert source, rule or alertmanager export enum AlertingAction { @@ -113,10 +119,25 @@ export enum AlertingAction { const AlwaysSupported = true; const NotSupported = false; -export type Action = AlertmanagerAction | AlertingAction | AlertRuleAction; +export type Action = AlertmanagerAction | AlertingAction | AlertRuleAction | FolderBulkAction; export type Ability = [actionSupported: boolean, actionAllowed: boolean]; export type Abilities = Record; +/** + * This one will check for folder abilities + */ +export const useFolderBulkActionAbilities = (): Abilities => { + return { + [FolderBulkAction.Pause]: [AlwaysSupported, isAdmin()], + [FolderBulkAction.Delete]: [AlwaysSupported, isAdmin()], + }; +}; + +export const useFolderBulkActionAbility = (action: FolderBulkAction): Ability => { + const allAbilities = useFolderBulkActionAbilities(); + return allAbilities[action]; +}; + /** * This one will check for alerting abilities that don't apply to any particular alert source or alert rule */ diff --git a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx index 755937c2773..bfabaa16cff 100644 --- a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx @@ -1,10 +1,12 @@ import { groupBy } from 'lodash'; import { useEffect, useMemo, useRef } from 'react'; +import { config } from '@grafana/runtime'; import { Icon, Stack, Text } from '@grafana/ui'; import { GrafanaRuleGroupIdentifier, GrafanaRulesSourceSymbol } from 'app/types/unified-alerting'; import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto'; +import { FolderBulkActionsButton } from '../components/folder-bulk-actions/FolderBulkActionsButton'; import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { groups } from '../utils/navigation'; @@ -42,6 +44,8 @@ export function PaginatedGrafanaLoader() { const groupsByFolder = useMemo(() => groupBy(groupsPage, 'folderUid'), [groupsPage]); + const isFolderBulkActionsEnabled = config.featureToggles.alertingBulkActionsInUI; + return ( @@ -59,6 +63,7 @@ export function PaginatedGrafanaLoader() { } + actions={isFolderBulkActionsEnabled ? : null} > {groups.map((group) => ( {{folderName}} folder. Nested folders will not be affected.", + "delete-modal-title": "Delete", + "error": "Failed to execute action for folder: {{error}}", + "more-button": { + "title": "Folder bulk Actions", + "tooltip": "Folder bulk Actions" + }, + "pause": { + "button": { + "label": "Pause all rule evaluation" + } + }, + "unpause": { + "button": { + "label": "Resume all rule evaluation" + } + } + }, "folder-selector": { "description-select-folder": "Select a folder to store your rule in." },