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 <gilles.de.mey@gmail.com>
Co-authored-by: Mariell Hoversholm <mariell.hoversholm@grafana.com>
This commit is contained in:
Sonia Aguilar
2025-05-07 11:25:31 +02:00
committed by GitHub
co-authored by Gilles De Mey Mariell Hoversholm
parent 3c904c701d
commit 277d271476
10 changed files with 385 additions and 4 deletions
@@ -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;
@@ -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<void, WithNotificationOptions<{ namespace: string }>>({
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<void, WithNotificationOptions<{ namespace: string }>>({
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<void, WithNotificationOptions<{ namespace: string }>>({
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,
},
};
},
}),
}),
});
@@ -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<HTMLButtonElement>) {
const MoreButton = forwardRef(function MoreButton(
props: ButtonProps & { title?: string },
ref: Ref<HTMLButtonElement>
) {
return (
<Button
variant="secondary"
size="sm"
type="button"
aria-label={t('alerting.more-button.aria-label', 'More')}
aria-label={props.title ?? t('alerting.more-button.aria-label', 'More')}
ref={ref}
{...props}
>
<Stack direction="row" alignItems="center" gap={0}>
<Trans i18nKey="alerting.more-button.button-text">More</Trans>
{props.title ?? <Trans i18nKey="alerting.more-button.button-text">More</Trans>}
<Icon name="angle-down" />
</Stack>
</Button>
@@ -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<void>;
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 (
<ConfirmModal
body={
<>
<Text element="p">
<Trans i18nKey="alerting.folder-bulk-actions.delete-modal-text" values={{ folderName: folderName }}>
This action will delete all alert rules in the <code>{'{{folderName}}'}</code> folder. Nested folders will
not be affected.
</Trans>
</Text>
<Space v={2} />
</>
}
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}
/>
);
});
@@ -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 && (
<>
<PauseUnpauseActionMenuItem
folderUID={folderUID}
action="pause"
executeAction={async (folderUID) => {
await pauseFolder({ namespace: folderUID }).unwrap();
await redirectToListView();
}}
isLoading={updateState.isLoading}
/>
<PauseUnpauseActionMenuItem
folderUID={folderUID}
action="unpause"
executeAction={async (folderUID) => {
await unpauseFolder({ namespace: folderUID }).unwrap();
await redirectToListView();
}}
isLoading={unpauseState.isLoading}
/>
</>
)}
{canDelete && (
<Menu.Item
label={t('alerting.folder-bulk-actions.delete.button.label', 'Delete rules')}
icon="trash-alt"
onClick={() => setIsDeleteModalOpen(true)}
disabled={deleteState.isLoading}
/>
)}
</>
);
return (
<>
<Dropdown overlay={<Menu>{menuItems}</Menu>}>
<IconButton
name="ellipsis-v"
size="sm"
aria-label={t('alerting.folder-bulk-actions.more-button.title', 'Folder bulk Actions')}
tooltip={t('alerting.folder-bulk-actions.more-button.tooltip', 'Folder bulk Actions')}
/>
</Dropdown>
<DeleteModal
isOpen={isDeleteModalOpen}
onConfirm={onConfirmDelete}
onDismiss={() => 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;
}
@@ -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<void>;
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 <Menu.Item label={label} icon={icon} disabled={isLoading} onClick={onActionClick} />;
}
@@ -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(<FolderBulkActionsButton folderUID={folderUID} key="folder-bulk-actions" />);
}
}
}
}
@@ -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<T extends Action> = Record<T, Ability>;
/**
* This one will check for folder abilities
*/
export const useFolderBulkActionAbilities = (): Abilities<FolderBulkAction> => {
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
*/
@@ -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 (
<DataSourceSection name="Grafana" application="grafana" uid={GrafanaRulesSourceSymbol} isLoading={isLoading}>
<Stack direction="column" gap={1}>
@@ -59,6 +63,7 @@ export function PaginatedGrafanaLoader() {
</Text>
</Stack>
}
actions={isFolderBulkActionsEnabled ? <FolderBulkActionsButton folderUID={folderUid} /> : null}
>
{groups.map((group) => (
<GrafanaRuleGroupListItem
+38
View File
@@ -618,6 +618,17 @@
"link-dashboard-and-panel": "Link dashboard and panel",
"placeholder-value-input-default": "Enter custom annotation content..."
},
"bulk-actions": {
"delete": {
"success": "Rules successfully deleted from folder"
},
"pause": {
"success": "Rules evaluation successfully paused for folder"
},
"unpause": {
"success": "Rules successfully unpaused for this folder"
}
},
"central-alert-history": {
"details": {
"error": "Error loading rule for this event.",
@@ -1031,6 +1042,33 @@
"filter-view-results": {
"aria-label-filteredrulelist": "filtered-rule-list"
},
"folder-bulk-actions": {
"delete": {
"button": {
"label": "Delete rules"
}
},
"delete-modal-confirmation-text": "Delete",
"delete-modal-delete-button": "Delete",
"delete-modal-deleting": "Deleting...",
"delete-modal-text": "This action will delete all alert rules in the <1>{{folderName}}</1> 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."
},