Alerting: Delete permanently deleted alert rules. (#102960)

* add alertingDeletePermanently ff

* add delete permanently button in Recently deleted page

* use ff to protect delete permanent button column

* update transations and prettier

* invalidate tag and update transalations keys

* add test

* prettier

* refactor test

* restore wrong change in deleteRulerRuleGroupHandler

* update test

* linter

* Alerting: Permanent delete rules code review (#103288)

* Update feature toggle name and description

* Rename mutation endpoint

* Fix typo and update text to be "permanently delete"

* Update feature toggle and test

* Move delete button into actions column

* generate ff

* address feedback

---------

Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
This commit is contained in:
Sonia Aguilar
2025-04-03 14:18:25 +03:00
committed by GitHub
co-authored by Tom Ratcliffe
parent 50825a8299
commit 3450d243b9
16 changed files with 255 additions and 34 deletions
@@ -1070,6 +1070,11 @@ export interface FeatureToggles {
*/
extensionSidebar?: boolean;
/**
* Enables UI functionality to permanently delete alert rules
* @default true
*/
alertingRulePermanentlyDelete?: boolean;
/**
* Enables the UI functionality to recover and view deleted alert rules
* @default true
*/
+10
View File
@@ -1838,6 +1838,16 @@ var (
FrontendOnly: true,
Owner: grafanaObservabilityLogsSquad,
},
{
Name: "alertingRulePermanentlyDelete",
Description: "Enables UI functionality to permanently delete alert rules",
FrontendOnly: true,
Stage: FeatureStageGeneralAvailability,
Owner: grafanaAlertingSquad,
HideFromAdminPage: true,
HideFromDocs: true,
Expression: "true", // enabled by default
},
{
Name: "alertingRuleRecoverDeleted",
Description: "Enables the UI functionality to recover and view deleted alert rules",
+1
View File
@@ -242,6 +242,7 @@ azureMonitorLogsBuilderEditor,preview,@grafana/partner-datasources,false,false,f
localeFormatPreference,experimental,@grafana/grafana-frontend-platform,false,false,false
unifiedStorageGrpcConnectionPool,experimental,@grafana/search-and-storage,false,false,false
extensionSidebar,experimental,@grafana/observability-logs,false,false,true
alertingRulePermanentlyDelete,GA,@grafana/alerting-squad,false,false,true
alertingRuleRecoverDeleted,GA,@grafana/alerting-squad,false,false,true
xrayApplicationSignals,experimental,@grafana/aws-datasources,false,false,true
multiTenantTempCredentials,experimental,@grafana/aws-datasources,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
242 localeFormatPreference experimental @grafana/grafana-frontend-platform false false false
243 unifiedStorageGrpcConnectionPool experimental @grafana/search-and-storage false false false
244 extensionSidebar experimental @grafana/observability-logs false false true
245 alertingRulePermanentlyDelete GA @grafana/alerting-squad false false true
246 alertingRuleRecoverDeleted GA @grafana/alerting-squad false false true
247 xrayApplicationSignals experimental @grafana/aws-datasources false false true
248 multiTenantTempCredentials experimental @grafana/aws-datasources false false false
+4
View File
@@ -979,6 +979,10 @@ const (
// Enables the extension sidebar
FlagExtensionSidebar = "extensionSidebar"
// FlagAlertingRulePermanentlyDelete
// Enables UI functionality to permanently delete alert rules
FlagAlertingRulePermanentlyDelete = "alertingRulePermanentlyDelete"
// FlagAlertingRuleRecoverDeleted
// Enables the UI functionality to recover and view deleted alert rules
FlagAlertingRuleRecoverDeleted = "alertingRuleRecoverDeleted"
+33
View File
@@ -249,6 +249,23 @@
"hideFromDocs": true
}
},
{
"metadata": {
"name": "alertingDeletePermanently",
"resourceVersion": "1742988550168",
"creationTimestamp": "2025-03-26T11:29:10Z",
"deletionTimestamp": "2025-04-02T14:29:49Z"
},
"spec": {
"description": "Enables the UI functionality to delete permanently alert rules",
"stage": "GA",
"codeowner": "@grafana/alerting-squad",
"frontend": true,
"hideFromAdminPage": true,
"hideFromDocs": true,
"expression": "true"
}
},
{
"metadata": {
"name": "alertingDisableSendAlertsExternal",
@@ -437,6 +454,22 @@
"expression": "false"
}
},
{
"metadata": {
"name": "alertingRulePermanentlyDelete",
"resourceVersion": "1743604189392",
"creationTimestamp": "2025-04-02T14:29:49Z"
},
"spec": {
"description": "Enables UI functionality to permanently delete alert rules",
"stage": "GA",
"codeowner": "@grafana/alerting-squad",
"frontend": true,
"hideFromAdminPage": true,
"hideFromDocs": true,
"expression": "true"
}
},
{
"metadata": {
"name": "alertingRuleRecoverDeleted",
@@ -423,5 +423,12 @@ export const alertRuleApi = alertingApi.injectEndpoints({
},
providesTags: ['DeletedRules'],
}),
permanentlyDeleteRule: build.mutation<void, { guid: string }>({
query: ({ guid }) => ({
url: `/api/ruler/${GRAFANA_RULES_SOURCE_NAME}/api/v1/trash/rule/guid/${guid}`,
method: 'DELETE',
}),
invalidatesTags: ['DeletedRules'],
}),
}),
});
@@ -0,0 +1,63 @@
import { css } from '@emotion/css';
import { ComponentProps } from 'react';
import { ConfirmModal, Stack, useStyles2 } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { Trans, t } from 'app/core/internationalization';
import { alertRuleApi } from '../../../api/alertRuleApi';
type ModalProps = Pick<ComponentProps<typeof ConfirmModal>, 'isOpen' | 'onDismiss'> & {
isOpen: boolean;
guid?: string;
};
export const ConfirmDeletedPermanentlyModal = ({ isOpen, onDismiss, guid }: ModalProps) => {
const [remove] = alertRuleApi.endpoints.permanentlyDeleteRule.useMutation();
const title = t('alerting.deleted-rules.delete-modal.title', 'Permanently delete alert rule');
const confirmText = t('alerting.deleted-rules.delete-modal.confirm', 'Yes, permanently delete');
const appNotification = useAppNotification();
const styles = useStyles2(getStyles);
async function onDeleteConfirm() {
if (!guid) {
return;
}
return remove({ guid })
.then(() => {
onDismiss();
appNotification.success(t('alerting.deleted-rules.delete-modal.success', 'Alert rule permanently deleted'));
})
.catch((err) => {
appNotification.error(
t('alerting.deleted-rules.delete-modal.error', 'Could not permanently delete alert rule')
);
});
}
return (
<ConfirmModal
isOpen={isOpen}
title={title}
confirmText={confirmText}
modalClass={styles.modal}
confirmButtonVariant="destructive"
body={
<Stack direction="column" gap={2}>
<Trans i18nKey="alerting.deleted-rules.delete-modal.body">
Are you sure you want to permanently delete this alert rule? This action cannot be undone.
</Trans>
</Stack>
}
onConfirm={onDeleteConfirm}
onDismiss={onDismiss}
/>
);
};
const getStyles = () => ({
modal: css({
width: '700px',
}),
});
@@ -1,3 +1,4 @@
import { produce } from 'immer';
import { render, screen } from 'test/test-utils';
import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList';
@@ -5,15 +6,17 @@ import { DashboardSearchItemType } from 'app/features/search/types';
import { AccessControlAction } from 'app/types/accessControl';
import { setupMswServer } from '../../../mockApi';
import { mockFolder } from '../../../mocks';
import { grantUserRole, mockFolder } from '../../../mocks';
import { grafanaRulerRule } from '../../../mocks/grafanaRulerApi';
import { setFolderResponse } from '../../../mocks/server/configure';
import { grantPermissionsHelper } from '../../../test/test-utils';
import { grantPermissionsHelper, testWithFeatureToggles } from '../../../test/test-utils';
import { DeletedRules } from './DeletedRules';
setupMswServer();
describe('render Deleted rules page', () => {
testWithFeatureToggles(['alertingRulePermanentlyDelete', 'alertingRuleRecoverDeleted', 'alertRuleRestore']);
beforeEach(() => {
grantUserRole('Admin');
grantPermissionsHelper([
AccessControlAction.AlertingRuleCreate,
AccessControlAction.AlertingRuleRead,
@@ -21,26 +24,38 @@ describe('render Deleted rules page', () => {
AccessControlAction.AlertingRuleDelete,
AccessControlAction.AlertingInstanceCreate,
]);
it('should show recently deleted rules, and restore button', async () => {
const folder = {
title: 'Folder A',
uid: grafanaRulerRule.grafana_alert.namespace_uid,
id: 1,
type: DashboardSearchItemType.DashDB,
accessControl: {
[AccessControlAction.AlertingRuleUpdate]: true,
},
};
setFolderResponse(mockFolder(folder));
});
const { user } = render(
<>
<AppNotificationList />
<DeletedRules deletedRules={[grafanaRulerRule]} />
</>
);
function renderDeletedRules() {
const folder = {
title: 'Folder A',
uid: grafanaRulerRule.grafana_alert.namespace_uid,
id: 1,
type: DashboardSearchItemType.DashDB,
accessControl: {
[AccessControlAction.AlertingRuleUpdate]: true,
},
};
setFolderResponse(mockFolder(folder));
const deletedRule = produce(grafanaRulerRule, (draft) => {
draft.grafana_alert.guid = '1234';
});
return render(
<>
<AppNotificationList />
<DeletedRules deletedRules={[deletedRule]} />
</>
);
}
describe('render Deleted rules page', () => {
it('should show recently deleted rules', async () => {
renderDeletedRules();
expect(screen.getByText('Grafana-rule')).toBeInTheDocument();
});
it('should render restore button', async () => {
const { user } = renderDeletedRules();
const restoreButtons = screen.getAllByRole('button', { name: /restore/i });
await user.click(restoreButtons[0]);
expect(
@@ -50,4 +65,16 @@ describe('render Deleted rules page', () => {
await user.click(screen.getByText(/yes, restore deleted rule/i));
expect(await screen.findByRole('status')).toHaveTextContent('Rule added successfully');
});
it('should render permanently delete button', async () => {
const { user } = renderDeletedRules();
const restoreButtons = screen.getAllByRole('button', { name: /permanently delete/i });
await user.click(restoreButtons[0]);
expect(
screen.getByText(/are you sure you want to permanently delete this alert rule\? this action cannot be undone./i)
).toBeInTheDocument();
await user.click(screen.getByText(/yes, permanently delete/i));
expect(await screen.findByRole('status')).toHaveTextContent('Alert rule permanently deleted');
});
});
@@ -6,8 +6,10 @@ import { Trans, t } from 'app/core/internationalization';
import { GrafanaRuleDefinition, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
import { trackDeletedRuleRestoreFail, trackDeletedRuleRestoreSuccess } from '../../../Analytics';
import { shouldAllowPermanentlyDeletingRules } from '../../../featureToggles';
import { UpdatedByUser } from '../../rule-viewer/tabs/version-history/UpdatedBy';
import { ConfirmDeletedPermanentlyModal } from './ConfirmDeletePermanantlyModal';
import { ConfirmRestoreDeletedRuleModal } from './ConfirmRestoreDeletedRuleModal';
const DELETED_RULES_PAGE_SIZE = 30;
@@ -18,7 +20,8 @@ interface DeletedRulesProps {
export function DeletedRules({ deletedRules }: DeletedRulesProps) {
const [confirmRestore, setConfirmRestore] = useState(false);
const [restoreRule, setRestoreRule] = useState<RulerGrafanaRuleDTO | undefined>();
const [guidToDelete, setGuidToDelete] = useState<string | undefined>();
const confirmDeletePermanently = guidToDelete !== undefined;
const unknown = t('alerting.deleted-rules.unknown', 'Unknown');
if (deletedRules.length === 0) {
@@ -40,9 +43,23 @@ export function DeletedRules({ deletedRules }: DeletedRulesProps) {
setRestoreRule(ruleTorestore);
};
const hideConfirmation = () => {
const hideConfirmationForRestore = () => {
setConfirmRestore(false);
};
const hideConfirmationForDelete = () => {
setGuidToDelete(undefined);
};
const showDeleteConfirmation = (id: string) => {
const ruleTorestore = deletedRules.find((rule) => getRowId(rule.grafana_alert) === id);
if (!ruleTorestore) {
return;
}
setGuidToDelete(ruleTorestore.grafana_alert.guid);
};
const shouldAllowRemovePermanently = shouldAllowPermanentlyDeletingRules();
const columns: Array<Column<(typeof deletedRules)[0]>> = [
{
@@ -105,6 +122,18 @@ export function DeletedRules({ deletedRules }: DeletedRulesProps) {
>
<Trans i18nKey="alerting.deleted-rules.restore">Restore</Trans>
</Button>
{shouldAllowRemovePermanently && (
<Button
variant="destructive"
size="sm"
icon="trash-alt"
onClick={() => {
showDeleteConfirmation(getRowId(row.original.grafana_alert));
}}
>
<Trans i18nKey="alerting.deleted-rules.permanently-delete">Permanently delete</Trans>
</Button>
)}
</Stack>
);
},
@@ -124,10 +153,15 @@ export function DeletedRules({ deletedRules }: DeletedRulesProps) {
<ConfirmRestoreDeletedRuleModal
ruleToRestore={restoreRule}
isOpen={confirmRestore}
onDismiss={hideConfirmation}
onDismiss={hideConfirmationForRestore}
onRestoreSucess={trackDeletedRuleRestoreSuccess}
onRestoreError={trackDeletedRuleRestoreFail}
/>
<ConfirmDeletedPermanentlyModal
guid={guidToDelete}
isOpen={confirmDeletePermanently}
onDismiss={hideConfirmationForDelete}
/>
</>
);
}
@@ -11,3 +11,6 @@ export const useGrafanaManagedRecordingRulesSupport = () =>
export const shouldAllowRecoveringDeletedRules = () =>
(isAdmin() && config.featureToggles.alertingRuleRecoverDeleted && config.featureToggles.alertRuleRestore) ?? false;
export const shouldAllowPermanentlyDeletingRules = () =>
(shouldAllowRecoveringDeletedRules() && config.featureToggles.alertingRulePermanentlyDelete) ?? false;
@@ -6,6 +6,10 @@ exports[`AlertRule abilities should report no permissions while we are loading d
false,
false,
],
"delete-alert-rule-permanently": [
false,
false,
],
"duplicate-alert-rule": [
false,
false,
@@ -47,6 +51,10 @@ exports[`AlertRule abilities should report that all actions are supported for a
true,
false,
],
"delete-alert-rule-permanently": [
true,
false,
],
"duplicate-alert-rule": [
true,
false,
@@ -88,6 +88,7 @@ export enum AlertRuleAction {
ModifyExport = 'modify-export-rule',
Pause = 'pause-alert-rule',
Restore = 'restore-alert-rule',
DeletePermanently = 'delete-alert-rule-permanently',
}
// this enum lists all of the actions we can perform within alerting in general, not linked to a specific
@@ -234,6 +235,10 @@ export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities<AlertRul
[AlertRuleAction.ModifyExport]: [isGrafanaManagedAlertRule, exportAllowed],
[AlertRuleAction.Pause]: [MaybeSupportedUnlessImmutable && isGrafanaManagedAlertRule, isEditable ?? false],
[AlertRuleAction.Restore]: [MaybeSupportedUnlessImmutable && isGrafanaManagedAlertRule, isEditable ?? false],
[AlertRuleAction.DeletePermanently]: [
MaybeSupportedUnlessImmutable && isGrafanaManagedAlertRule,
(isRemovable && isAdmin()) ?? false,
],
};
return abilities;
@@ -281,6 +286,10 @@ export function useAllRulerRuleAbilities(
[AlertRuleAction.ModifyExport]: [isGrafanaManagedAlertRule, exportAllowed],
[AlertRuleAction.Pause]: [MaybeSupportedUnlessImmutable && isGrafanaManagedAlertRule, isEditable ?? false],
[AlertRuleAction.Restore]: [MaybeSupportedUnlessImmutable && isGrafanaManagedAlertRule, isEditable ?? false],
[AlertRuleAction.DeletePermanently]: [
MaybeSupportedUnlessImmutable && isGrafanaManagedAlertRule,
(isRemovable && isAdmin()) ?? false,
],
};
return abilities;
@@ -111,6 +111,22 @@ export const deleteRulerRuleGroupHandler = (options?: HandlerOptions) =>
}
);
export const deleteRulerRulePermanentlyHandler = (options?: HandlerOptions) =>
http.delete<{ ruleGuid: string }>(
`/api/ruler/grafana/api/v1/trash/rule/guid/:ruleGuid`,
({ params: { ruleGuid } }) => {
if (options?.response) {
return options.response;
}
if (grafanaRulerRule.grafana_alert.guid !== ruleGuid) {
return new HttpResponse(null, { status: 403 });
}
return HttpResponse.json({ status: 202 });
}
);
export const rulerRuleHandler = () => {
const grafanaRules = new Map<string, RulerGrafanaRuleDTO>(
[grafanaRulerRule].map((rule) => [rule.grafana_alert.uid, rule])
@@ -198,6 +214,7 @@ const handlers = [
historyHandler(),
updateRulerRuleNamespaceHandler(),
deleteRulerRuleGroupHandler(),
deleteRulerRulePermanentlyHandler(),
rulerRuleVersionHistoryHandler(),
];
export default handlers;
@@ -128,7 +128,7 @@ export function RecentlyDeletedActions() {
<Trans i18nKey="recently-deleted.buttons.restore">Restore</Trans>
</Button>
<Button onClick={showDeleteModal} variant="destructive">
<Trans i18nKey="recently-deleted.buttons.delete">Delete permanently</Trans>
<Trans i18nKey="recently-deleted.buttons.delete">Permanently delete</Trans>
</Button>
</Stack>
);
@@ -1,5 +1,3 @@
import { config } from '@grafana/runtime';
const graphitePlugin = async () =>
await import(/* webpackChunkName: "graphitePlugin" */ 'app/plugins/datasource/graphite/module');
const cloudwatchPlugin = async () =>
@@ -57,13 +55,7 @@ const stateTimelinePanel = async () =>
await import(/* webpackChunkName: "stateTimelinePanel" */ 'app/plugins/panel/state-timeline/module');
const statusHistoryPanel = async () =>
await import(/* webpackChunkName: "statusHistoryPanel" */ 'app/plugins/panel/status-history/module');
const tablePanel = async () => {
if (config.featureToggles.tableNextGen) {
return await import(/* webpackChunkName: "tableNewPanel" */ 'app/plugins/panel/table/table-new/module');
} else {
return await import(/* webpackChunkName: "tablePanel" */ 'app/plugins/panel/table/module');
}
};
const tablePanel = async () => await import(/* webpackChunkName: "tablePanel" */ 'app/plugins/panel/table/module');
const textPanel = async () => await import(/* webpackChunkName: "textPanel" */ 'app/plugins/panel/text/module');
const timeseriesPanel = async () =>
await import(/* webpackChunkName: "timeseriesPanel" */ 'app/plugins/panel/timeseries/module');
+9 -1
View File
@@ -478,8 +478,16 @@
"without-soft-delete": "Deleting this rule will permanently remove it from your alert rule list. Are you sure you want to delete this rule?"
},
"deleted-rules": {
"delete-modal": {
"body": "Are you sure you want to permanently delete this alert rule? This action cannot be undone.",
"confirm": "Yes, permanently delete",
"error": "Could not permanently delete alert rule",
"success": "Alert rule permanently deleted",
"title": "Permanently delete alert rule"
},
"empty-state-title": "No recently deleted rules found",
"errorloading": "Failed to load alert deleted rules",
"permanently-delete": "Permanently delete",
"restore": "Restore",
"restore-deleted-manually": "Your alert rule could not be restored. This may be due to changes to other entities such as contact points, data sources etc. Please manually restore the deleted rule by editing the rule and saving it.",
"restore-modal": {
@@ -5651,7 +5659,7 @@
},
"recently-deleted": {
"buttons": {
"delete": "Delete permanently",
"delete": "Permanently delete",
"restore": "Restore"
},
"page": {