diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 5c6da792edb..2ffc97577c3 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -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 */ diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d8e4e73a2d4..778035d1b23 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -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", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 92e3b83b362..47f80e9c926 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -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 diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index ccc9244e45c..eba33f3f9e9 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -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" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 82a0c75974d..e6e9e4b7fa5 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -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", diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts index 1606ceb4ae0..1b9f006ad8c 100644 --- a/public/app/features/alerting/unified/api/alertRuleApi.ts +++ b/public/app/features/alerting/unified/api/alertRuleApi.ts @@ -423,5 +423,12 @@ export const alertRuleApi = alertingApi.injectEndpoints({ }, providesTags: ['DeletedRules'], }), + permanentlyDeleteRule: build.mutation({ + query: ({ guid }) => ({ + url: `/api/ruler/${GRAFANA_RULES_SOURCE_NAME}/api/v1/trash/rule/guid/${guid}`, + method: 'DELETE', + }), + invalidatesTags: ['DeletedRules'], + }), }), }); diff --git a/public/app/features/alerting/unified/components/rules/deleted-rules/ConfirmDeletePermanantlyModal.tsx b/public/app/features/alerting/unified/components/rules/deleted-rules/ConfirmDeletePermanantlyModal.tsx new file mode 100644 index 00000000000..fd505b6d731 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/deleted-rules/ConfirmDeletePermanantlyModal.tsx @@ -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, '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 ( + + + Are you sure you want to permanently delete this alert rule? This action cannot be undone. + + + } + onConfirm={onDeleteConfirm} + onDismiss={onDismiss} + /> + ); +}; + +const getStyles = () => ({ + modal: css({ + width: '700px', + }), +}); diff --git a/public/app/features/alerting/unified/components/rules/deleted-rules/DeletedRules.test.tsx b/public/app/features/alerting/unified/components/rules/deleted-rules/DeletedRules.test.tsx index 04e3cb87015..dd0d74c503a 100644 --- a/public/app/features/alerting/unified/components/rules/deleted-rules/DeletedRules.test.tsx +++ b/public/app/features/alerting/unified/components/rules/deleted-rules/DeletedRules.test.tsx @@ -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( - <> - - - - ); +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( + <> + + + + ); +} +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'); + }); }); diff --git a/public/app/features/alerting/unified/components/rules/deleted-rules/DeletedRules.tsx b/public/app/features/alerting/unified/components/rules/deleted-rules/DeletedRules.tsx index fde47a4a472..1a5a8781a0c 100644 --- a/public/app/features/alerting/unified/components/rules/deleted-rules/DeletedRules.tsx +++ b/public/app/features/alerting/unified/components/rules/deleted-rules/DeletedRules.tsx @@ -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(); - + const [guidToDelete, setGuidToDelete] = useState(); + 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> = [ { @@ -105,6 +122,18 @@ export function DeletedRules({ deletedRules }: DeletedRulesProps) { > Restore + {shouldAllowRemovePermanently && ( + + )} ); }, @@ -124,10 +153,15 @@ export function DeletedRules({ deletedRules }: DeletedRulesProps) { + ); } diff --git a/public/app/features/alerting/unified/featureToggles.ts b/public/app/features/alerting/unified/featureToggles.ts index 0d6409d3099..5cd667c6638 100644 --- a/public/app/features/alerting/unified/featureToggles.ts +++ b/public/app/features/alerting/unified/featureToggles.ts @@ -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; diff --git a/public/app/features/alerting/unified/hooks/__snapshots__/useAbilities.test.tsx.snap b/public/app/features/alerting/unified/hooks/__snapshots__/useAbilities.test.tsx.snap index 45a27ea0e07..0be5999894f 100644 --- a/public/app/features/alerting/unified/hooks/__snapshots__/useAbilities.test.tsx.snap +++ b/public/app/features/alerting/unified/hooks/__snapshots__/useAbilities.test.tsx.snap @@ -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, diff --git a/public/app/features/alerting/unified/hooks/useAbilities.ts b/public/app/features/alerting/unified/hooks/useAbilities.ts index 098913b2375..db00603970f 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.ts +++ b/public/app/features/alerting/unified/hooks/useAbilities.ts @@ -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 } ); +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( [grafanaRulerRule].map((rule) => [rule.grafana_alert.uid, rule]) @@ -198,6 +214,7 @@ const handlers = [ historyHandler(), updateRulerRuleNamespaceHandler(), deleteRulerRuleGroupHandler(), + deleteRulerRulePermanentlyHandler(), rulerRuleVersionHistoryHandler(), ]; export default handlers; diff --git a/public/app/features/browse-dashboards/components/RecentlyDeletedActions.tsx b/public/app/features/browse-dashboards/components/RecentlyDeletedActions.tsx index 0dddedf7c39..186c0356b43 100644 --- a/public/app/features/browse-dashboards/components/RecentlyDeletedActions.tsx +++ b/public/app/features/browse-dashboards/components/RecentlyDeletedActions.tsx @@ -128,7 +128,7 @@ export function RecentlyDeletedActions() { Restore ); diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 0b449003be4..9f8ac9cda32 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -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'); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 0b39266d6f4..e5cb280e22d 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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": {