From f9471ac10b65e08343b08d82e1209f1ae7006955 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Thu, 27 Mar 2025 15:39:26 +0100 Subject: [PATCH] Alerting: Recover deleted alert rules (#101869) * add column guid to alert rule table and rule_guid to rule version table + populate the new field with UUID * update storage and domain models * patch GUID * ignore GUID in fingerprint tests * get alert rule versions by GUID * update rule delete to reset uid in history instead of deleting rows * add test * Update delete rule to require UserUID, remove all versions and create "delete" version that holds information about who and when deleted the rule * update usages of the delete method * add list deleted rules to store * list api to support deleted query parameter * add list deleted rules to store ^ Conflicts: ^ pkg/services/ngalert/store/alert_rule_test.go * list api to support deleted query parameter * Restore deleted rules * fix recovering manually recording rules * update role control for the trash route * add alertingRuleRecoverDeleted feature toggle enabled by default * fix access control for trash menu item in the navtree * Add alertingRuleRecoverDeleted feature toggle, add Delete permanently alert rule menu option * hide remove permanently functionality in the UI until we have the api available for it * discard wrong changes after merging * Reverted changes in pkg/apis/dashboard_manifest.go after fixing conflicts * Reverted changes in go.work.sum after fixing conflicts * Reverted changes in pkg/apis/dashboard_manifest.go after fixing conflicts * Reverted changes in pkg/services/ngalert/models/alert_rule.go after fixing conflicts * Reverted changes in pkg/apis/dashboard_manifest.go after fixing conflicts * restore delete permanently and add the queryparam for it * update snapshots * fix translations * protect trash route by the feature toggles * use the new rulerRuleType for checks * revert ability to delete permanently and alert rule from the ui * remove unnecessary update after reverting * fix ff * add tracking * use recently deleted instead of trash * create isRecoverDeletedRulesEnabled for checking ffs * address pr feedback 1 * include alert rule uid in getRowId * use RulerGrafanaRulesConfigDTO for deleted rules response * use isLoading in the AlertingPageWrapper * fix wrong check for recording rules type * add test for restoring manually through the alert rule form * add test for restoring deleted rule * use importAlertingComponent * udpate mock * address pr feedback * update translations * address feedback * address feedback 2 * address feedback 3 * address feedback 4 * move transformation of the response to the endpoint * fix typo * refactor grafanaRuleDtoToFormValues * update translations * use guid for identifying rows * prettier * use ONLY the guid for identifying rows * Add provides/invalidates tags for deleted rules --------- Co-authored-by: Yuri Tseretyan Co-authored-by: Tom Ratcliffe --- .betterer.results | 8 - .../src/types/featureToggles.gen.ts | 5 + pkg/services/featuremgmt/registry.go | 10 ++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 19 ++ pkg/services/navtree/navtreeimpl/navtree.go | 8 + .../app/core/utils/navBarItem-translations.ts | 4 + public/app/features/alerting/routes.tsx | 15 ++ .../features/alerting/unified/Analytics.ts | 8 + .../alerting/unified/api/alertRuleApi.ts | 22 ++- .../alerting/unified/api/alertingApi.ts | 1 + .../components/rule-viewer/AlertRuleMenu.tsx | 38 +++- .../components/rule-viewer/DeleteModal.tsx | 20 ++- .../ConfirmRestoreDeletedRuleModal.tsx | 167 ++++++++++++++++++ .../rules/deleted-rules/DeletedRules.test.tsx | 53 ++++++ .../rules/deleted-rules/DeletedRules.tsx | 137 ++++++++++++++ .../rules/deleted-rules/DeletedRulesPage.tsx | 36 ++++ .../deleted-rules/useRestoreDeletedRule.ts | 19 ++ .../alerting/unified/featureToggles.ts | 5 + .../unified/rule-editor/RuleEditor.tsx | 17 +- .../RuleEditorGrafanaRules.test.tsx | 40 ++++- .../RuleEditorGrafanaRules.test.tsx.snap | 110 ++++++++++++ .../alerting/unified/utils/rule-form.ts | 51 ++++++ .../__mocks__/store.navIndex.mock.ts | 6 + public/app/types/unified-alerting-dto.ts | 5 + public/locales/en-US/grafana.json | 36 ++++ public/test/helpers/alertingRuleEditor.tsx | 15 +- 28 files changed, 833 insertions(+), 27 deletions(-) create mode 100644 public/app/features/alerting/unified/components/rules/deleted-rules/ConfirmRestoreDeletedRuleModal.tsx create mode 100644 public/app/features/alerting/unified/components/rules/deleted-rules/DeletedRules.test.tsx create mode 100644 public/app/features/alerting/unified/components/rules/deleted-rules/DeletedRules.tsx create mode 100644 public/app/features/alerting/unified/components/rules/deleted-rules/DeletedRulesPage.tsx create mode 100644 public/app/features/alerting/unified/components/rules/deleted-rules/useRestoreDeletedRule.ts diff --git a/.betterer.results b/.betterer.results index c7f5454900f..5c91eb8941a 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2041,14 +2041,6 @@ exports[`better eslint`] = { "public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], - "public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "2"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "3"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "4"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "5"] - ], "public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] ], diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 59699d7de91..4ed992d0956 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1048,4 +1048,9 @@ export interface FeatureToggles { * Enables the unified storage grpc connection pool */ unifiedStorageGrpcConnectionPool?: boolean; + /** + * Enables the UI functionality to recover and view deleted alert rules + * @default true + */ + alertingRuleRecoverDeleted?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 49d12b76675..e651f09a6ee 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1806,6 +1806,16 @@ var ( HideFromAdminPage: true, HideFromDocs: true, }, + { + Name: "alertingRuleRecoverDeleted", + Description: "Enables the UI functionality to recover and view deleted alert rules", + FrontendOnly: true, + Stage: FeatureStageGeneralAvailability, + Owner: grafanaAlertingSquad, + HideFromAdminPage: true, + HideFromDocs: true, + Expression: "true", // enabled by default + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index b9f364935e6..a6c70e9389e 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -238,3 +238,4 @@ noBackdropBlur,experimental,@grafana/grafana-frontend-platform,false,false,true alertingMigrationUI,experimental,@grafana/alerting-squad,false,false,true unifiedStorageHistoryPruner,experimental,@grafana/search-and-storage,false,false,false unifiedStorageGrpcConnectionPool,experimental,@grafana/search-and-storage,false,false,false +alertingRuleRecoverDeleted,GA,@grafana/alerting-squad,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 058e30627c4..8917e5b6ebf 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -962,4 +962,8 @@ const ( // FlagUnifiedStorageGrpcConnectionPool // Enables the unified storage grpc connection pool FlagUnifiedStorageGrpcConnectionPool = "unifiedStorageGrpcConnectionPool" + + // 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 75d8edd849d..34c64774a3a 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -437,6 +437,25 @@ "expression": "false" } }, + { + "metadata": { + "name": "alertingRuleRecoverDeleted", + "resourceVersion": "1741351039908", + "creationTimestamp": "2025-03-07T09:25:14Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-03-07 12:37:19.908258 +0000 UTC" + } + }, + "spec": { + "description": "Enables the UI functionality to recover and view deleted alert rules", + "stage": "GA", + "codeowner": "@grafana/alerting-squad", + "frontend": true, + "hideFromAdminPage": true, + "hideFromDocs": true, + "expression": "true" + } + }, { "metadata": { "name": "alertingRuleVersionHistoryRestore", diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index e859596520f..1016b4373cb 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -489,6 +489,14 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na }) } } + if c.SignedInUser.GetOrgRole() == org.RoleAdmin && s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertRuleRestore) && s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingRuleRecoverDeleted) { + alertChildNavs = append(alertChildNavs, &navtree.NavLink{ + Text: "Recently deleted", + SubTitle: "Any items listed here for more than 30 days will be automatically deleted.", + Id: "alerts/recently-deleted", + Url: s.cfg.AppSubURL + "/alerting/recently-deleted", + }) + } if c.SignedInUser.GetOrgRole() == org.RoleAdmin { alertChildNavs = append(alertChildNavs, &navtree.NavLink{ diff --git a/public/app/core/utils/navBarItem-translations.ts b/public/app/core/utils/navBarItem-translations.ts index eb533d88b07..b278a27f7c0 100644 --- a/public/app/core/utils/navBarItem-translations.ts +++ b/public/app/core/utils/navBarItem-translations.ts @@ -89,6 +89,8 @@ export function getNavTitle(navId: string | undefined) { return t('nav.alerting-groups.title', 'Active notifications'); case 'alerting-admin': return t('nav.alerting-admin.title', 'Settings'); + case 'alerts/recently-deleted': + return t('nav.alerts-recently-deleted.title', 'Recently deleted'); case 'cfg': return t('nav.config.title', 'Administration'); case 'cfg/general': @@ -253,6 +255,8 @@ export function getNavSubTitle(navId: string | undefined) { return t('nav.alerting-silences.subtitle', 'Stop notifications from one or more alerting rules'); case 'groups': return t('nav.alerting-groups.subtitle', 'See grouped alerts with active notifications'); + case 'alerts/recently-deleted': + return t('nav.alerts-recently-deleted.subtitle', 'See recently deleted alert rules'); case 'datasources': return t('nav.datasources.subtitle', 'Add and configure data sources'); case 'correlations': diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index e0cb793b655..1bd1159efd9 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -1,3 +1,5 @@ +import { Navigate } from 'react-router-dom-v5-compat'; + import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; import { config } from 'app/core/config'; import { GrafanaRouteComponent, RouteDescriptor } from 'app/core/navigation/types'; @@ -13,6 +15,7 @@ import { PERMISSIONS_NOTIFICATION_POLICIES_READ, } from './unified/components/notification-policies/permissions'; import { PERMISSIONS_TEMPLATES } from './unified/components/templates/permissions'; +import { shouldAllowRecoveringDeletedRules } from './unified/featureToggles'; import { evaluateAccess } from './unified/utils/access-control'; export function getAlertingRoutes(cfg = config): RouteDescriptor[] { @@ -209,6 +212,18 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { ) ), }, + { + path: '/alerting/recently-deleted/', + roles: () => ['Admin'], + component: shouldAllowRecoveringDeletedRules() + ? importAlertingComponent( + () => + import( + /* webpackChunkName: "RecentlyDeleted" */ 'app/features/alerting/unified/components/rules/deleted-rules/DeletedRulesPage' + ) + ) + : () => , + }, { path: '/alerting/new/:type?', pageClass: 'page-alerting', diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts index c209ec34249..415d0bd1408 100644 --- a/public/app/features/alerting/unified/Analytics.ts +++ b/public/app/features/alerting/unified/Analytics.ts @@ -217,6 +217,14 @@ export const trackRuleVersionsRestoreFail = async ( reportInteraction('grafana_alerting_rule_versions_restore_error', { ...payload }); }; +export const trackDeletedRuleRestoreSuccess = async () => { + reportInteraction('grafana_alerting_deleted_rule_restore_success'); +}; + +export const trackDeletedRuleRestoreFail = async () => { + reportInteraction('grafana_alerting_deleted_rule_restore_error'); +}; + interface RulesSearchInteractionPayload { filter: string; triggeredBy: 'typing' | 'component'; diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts index 080ea10fe2f..4eefa3b8b67 100644 --- a/public/app/features/alerting/unified/api/alertRuleApi.ts +++ b/public/app/features/alerting/unified/api/alertRuleApi.ts @@ -13,10 +13,12 @@ import { AlertQuery, Annotations, GrafanaAlertStateDecision, + GrafanaRuleDefinition, Labels, PostableRulerRuleGroupDTO, PromRulesResponse, RulerGrafanaRuleDTO, + RulerGrafanaRulesConfigDTO, RulerRuleGroupDTO, RulerRulesConfigDTO, } from 'app/types/unified-alerting-dto'; @@ -283,7 +285,11 @@ export const alertRuleApi = alertingApi.injectEndpoints({ deleteRuleGroupFromNamespace: build.mutation< RulerRuleGroupDTO, - WithNotificationOptions<{ rulerConfig: RulerDataSourceConfig; namespace: string; group: string }> + WithNotificationOptions<{ + rulerConfig: RulerDataSourceConfig; + namespace: string; + group: string; + }> >({ query: ({ rulerConfig, namespace, group, notificationOptions }) => { const successMessage = t('alerting.rule-groups.delete.success', 'Successfully deleted rule group'); @@ -302,6 +308,7 @@ export const alertRuleApi = alertingApi.injectEndpoints({ invalidatesTags: (_result, _error, { namespace, group, rulerConfig }) => [ { type: 'RuleGroup', id: `${rulerConfig.dataSourceUid}/${namespace}/${group}` }, { type: 'RuleNamespace', id: `${rulerConfig.dataSourceUid}/${namespace}` }, + 'DeletedRules', ], }), @@ -339,6 +346,7 @@ export const alertRuleApi = alertingApi.injectEndpoints({ { type: 'GrafanaRulerRule', id: rule.grafana_alert.uid } as const, { type: 'GrafanaRulerRuleVersion', id: rule.grafana_alert.uid } as const, ]), + 'DeletedRules', ]; }, }), @@ -413,5 +421,17 @@ export const alertRuleApi = alertingApi.injectEndpoints({ }), keepUnusedDataFor: 0, }), + getDeletedRules: build.query>, {}>({ + query: () => ({ + url: `/api/ruler/${GRAFANA_RULES_SOURCE_NAME}/api/v1/rules/`, + params: { deleted: 'true' }, + }), + transformResponse: (response: RulerGrafanaRulesConfigDTO) => { + const values = Object.values(response); + const deletedRules = values.length > 0 ? values[0][0]?.rules : []; + return deletedRules; + }, + providesTags: ['DeletedRules'], + }), }), }); diff --git a/public/app/features/alerting/unified/api/alertingApi.ts b/public/app/features/alerting/unified/api/alertingApi.ts index ae45cc7678d..e3c28894ea5 100644 --- a/public/app/features/alerting/unified/api/alertingApi.ts +++ b/public/app/features/alerting/unified/api/alertingApi.ts @@ -129,6 +129,7 @@ export const alertingApi = createApi({ 'ContactPoint', 'ContactPointsStatus', 'Receiver', + 'DeletedRules', ], endpoints: () => ({}), }); diff --git a/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx b/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx index f7a1237ae9f..afb6fe0c30c 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx @@ -1,6 +1,7 @@ import { AppEvents } from '@grafana/data'; import { ComponentSize, Dropdown, Menu } from '@grafana/ui'; import appEvents from 'app/core/app_events'; +import { t } from 'app/core/internationalization'; import MenuItemPauseRule from 'app/features/alerting/unified/components/MenuItemPauseRule'; import MoreButton from 'app/features/alerting/unified/components/MoreButton'; import { useRulePluginLinkExtension } from 'app/features/alerting/unified/plugins/useRulePluginLinkExtensions'; @@ -89,15 +90,33 @@ const AlertRuleMenu = ({ {canPause && rulerRuleType.grafana.rule(rulerRule) && groupIdentifier.groupOrigin === 'grafana' && ( )} - {canSilence && } + {canSilence && ( + + )} {/* TODO Migrate Declare Incident to plugin links extensions */} {shouldShowDeclareIncidentButton && } - {canDuplicate && handleDuplicateRule(identifier)} />} + {canDuplicate && ( + handleDuplicateRule(identifier)} + /> + )} {showDivider && } - {shareUrl && copyToClipboard(shareUrl)} />} + {shareUrl && ( + copyToClipboard(shareUrl)} + /> + )} {canExport && ( ]} /> @@ -114,7 +133,7 @@ const AlertRuleMenu = ({ <> handleDelete(rulerRule, groupIdentifier)} @@ -144,7 +163,14 @@ const ExportMenuItem = ({ identifier }: ExportMenuItemProps) => { } ); - return ; + return ( + + ); }; function copyToClipboard(text: string) { 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 c408a4fc37e..c860823da53 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx @@ -2,10 +2,11 @@ import { useCallback, useMemo, useState } from 'react'; import { locationService } from '@grafana/runtime'; import { ConfirmModal } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { dispatch } from 'app/store/store'; import { EditableRuleIdentifier, RuleGroupIdentifierV2 } from 'app/types/unified-alerting'; -import { shouldUsePrometheusRulesPrimary } from '../../featureToggles'; +import { shouldAllowRecoveringDeletedRules, shouldUsePrometheusRulesPrimary } from '../../featureToggles'; import { useDeleteRuleFromGroup } from '../../hooks/ruleGroup/useDeleteRuleFromGroup'; import { usePrometheusConsistencyCheck } from '../../hooks/usePrometheusConsistencyCheck'; import { fetchPromAndRulerRulesAction, fetchRulerRulesAction } from '../../state/actions'; @@ -25,6 +26,7 @@ export const useDeleteModal = (redirectToListView = false): DeleteModalHook => { const [ruleToDelete, setRuleToDelete] = useState(); const [deleteRuleFromGroup] = useDeleteRuleFromGroup(); const { waitForRemoval } = usePrometheusConsistencyCheck(); + const isSoftDeleteEnabled = shouldAllowRecoveringDeletedRules(); const dismissModal = useCallback(() => { setRuleToDelete(undefined); @@ -68,15 +70,25 @@ export const useDeleteModal = (redirectToListView = false): DeleteModalHook => { () => ( ), - [ruleToDelete, deleteRule, dismissModal] + [ruleToDelete, deleteRule, dismissModal, isSoftDeleteEnabled] ); return [modal, showModal, dismissModal]; diff --git a/public/app/features/alerting/unified/components/rules/deleted-rules/ConfirmRestoreDeletedRuleModal.tsx b/public/app/features/alerting/unified/components/rules/deleted-rules/ConfirmRestoreDeletedRuleModal.tsx new file mode 100644 index 00000000000..ad8846052d6 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/deleted-rules/ConfirmRestoreDeletedRuleModal.tsx @@ -0,0 +1,167 @@ +import { css } from '@emotion/css'; +import { ComponentProps } from 'react'; + +import { locationService } from '@grafana/runtime'; +import { Alert, CodeEditor, ConfirmModal, Stack, useStyles2 } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { getMessageFromError } from 'app/core/utils/errors'; +import { useAsync } from 'app/features/alerting/unified/hooks/useAsync'; +import { GrafanaRuleDefinition, RulerGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto'; + +import { useAddRuleToRuleGroup } from '../../../hooks/ruleGroup/useUpsertRuleFromRuleGroup'; +import { RuleFormValues } from '../../../types/rule-form'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource'; +import { stringifyErrorLike } from '../../../utils/misc'; +import { grafanaRuleDtoToFormValues } from '../../../utils/rule-form'; +import { rulerRuleType } from '../../../utils/rules'; +import { createRelativeUrl } from '../../../utils/url'; + +type ModalProps = Pick, 'isOpen' | 'onDismiss'> & { + isOpen: boolean; + ruleToRestore?: RulerGrafanaRuleDTO | undefined; + onRestoreSucess: () => void; + onRestoreError: (error: Error) => void; +}; + +export const ConfirmRestoreDeletedRuleModal = ({ + isOpen, + ruleToRestore, + onDismiss, + onRestoreSucess, + onRestoreError, +}: ModalProps) => { + const [restoreMethod, { error }] = useRestoreDeletedRule(); + const title = t('alerting.deleted-rules.restore-modal.title', 'Restore deleted alert rule'); + const errorTitle = t('alerting.deleted-rules.restore-modal.error', 'Could not restore deleted alert rule'); + const confirmText = !error + ? t('alerting.deleted-rules.restore-modal.confirm', 'Yes, restore deleted rule') + : 'Manually restore the rule'; + + const styles = useStyles2(getStyles); + + async function onRestoreConfirm() { + if (!ruleToRestore) { + return; + } + return restoreMethod + .execute(ruleToRestore) + .then(() => { + onDismiss(); + onRestoreSucess(); + }) + .catch((err) => { + onRestoreError(err); + }); + } + + async function onManualRestore() { + if (!ruleToRestore) { + return; + } + await redirectToRestoreForm(ruleToRestore); + } + + return ( + + + Are you sure you want to restore this deleted alert rule definition? + + +
{ruleToRestore && }
+ {error && ( + + + 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. + +
+                {stringifyErrorLike(error)}
+              
+
+ )} + + } + onConfirm={!error ? onRestoreConfirm : onManualRestore} + onDismiss={onDismiss} + /> + ); +}; + +function RulePreview({ rule }: { rule: RulerRuleDTO }) { + const styles = useStyles2(getStyles); + return ( +
+ +
+ ); +} + +const getStyles = () => ({ + content: css({ + flex: '1 1 100%', + }), + modal: css({ + width: '700px', + }), +}); + +export function useRestoreDeletedRule() { + const [addRuleToRuleGroup] = useAddRuleToRuleGroup(); + + return useAsync(async (deletedRule: RulerGrafanaRuleDTO) => { + const ruleGroupIdentifier = { + dataSourceName: GRAFANA_RULES_SOURCE_NAME, + namespaceName: deletedRule.grafana_alert.namespace_uid, + groupName: deletedRule.grafana_alert.rule_group, + }; + // save the new rule to the rule group + return addRuleToRuleGroup.execute(ruleGroupIdentifier, deletedRule); + }); +} + +const redirectToRestoreForm = async (ruleToRecover: RulerGrafanaRuleDTO) => { + let formValues: Partial | undefined; + const namespaceName = await backendSrv + .getFolderByUid(ruleToRecover.grafana_alert.namespace_uid) + .then((folder) => folder.title); + + try { + formValues = grafanaRuleDtoToFormValues(ruleToRecover, namespaceName); + } catch (err) { + const message = `Error getting rule values from the deleted rule: ${getMessageFromError(err)}`; + throw new Error(message); + } + + const urlPath = rulerRuleType.grafana.recordingRule(ruleToRecover) + ? '/alerting/new/grafana-recording' + : '/alerting/new'; + + const ruleFormUrl = createRelativeUrl(urlPath, { + isManualRestore: 'true', + defaults: JSON.stringify(formValues), + returnTo: location.pathname + location.search, + }); + + locationService.push(ruleFormUrl); +}; 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 new file mode 100644 index 00000000000..04e3cb87015 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/deleted-rules/DeletedRules.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from 'test/test-utils'; + +import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList'; +import { DashboardSearchItemType } from 'app/features/search/types'; +import { AccessControlAction } from 'app/types/accessControl'; + +import { setupMswServer } from '../../../mockApi'; +import { mockFolder } from '../../../mocks'; +import { grafanaRulerRule } from '../../../mocks/grafanaRulerApi'; +import { setFolderResponse } from '../../../mocks/server/configure'; +import { grantPermissionsHelper } from '../../../test/test-utils'; + +import { DeletedRules } from './DeletedRules'; + +setupMswServer(); +describe('render Deleted rules page', () => { + grantPermissionsHelper([ + AccessControlAction.AlertingRuleCreate, + AccessControlAction.AlertingRuleRead, + AccessControlAction.AlertingRuleUpdate, + 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( + <> + + + + ); + expect(screen.getByText('Grafana-rule')).toBeInTheDocument(); + + const restoreButtons = screen.getAllByRole('button', { name: /restore/i }); + await user.click(restoreButtons[0]); + expect( + screen.getByText(/are you sure you want to restore this deleted alert rule definition\?/i) + ).toBeInTheDocument(); + + await user.click(screen.getByText(/yes, restore deleted rule/i)); + expect(await screen.findByRole('status')).toHaveTextContent('Rule added successfully'); + }); +}); 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 new file mode 100644 index 00000000000..fde47a4a472 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/deleted-rules/DeletedRules.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react'; + +import { dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data'; +import { Button, Column, EmptyState, InteractiveTable, Stack } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; +import { GrafanaRuleDefinition, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; + +import { trackDeletedRuleRestoreFail, trackDeletedRuleRestoreSuccess } from '../../../Analytics'; +import { UpdatedByUser } from '../../rule-viewer/tabs/version-history/UpdatedBy'; + +import { ConfirmRestoreDeletedRuleModal } from './ConfirmRestoreDeletedRuleModal'; + +const DELETED_RULES_PAGE_SIZE = 30; + +interface DeletedRulesProps { + deletedRules: Array>; +} +export function DeletedRules({ deletedRules }: DeletedRulesProps) { + const [confirmRestore, setConfirmRestore] = useState(false); + const [restoreRule, setRestoreRule] = useState(); + + const unknown = t('alerting.deleted-rules.unknown', 'Unknown'); + + if (deletedRules.length === 0) { + return ( + + ); + } + + const showConfirmation = (id: string) => { + const ruleTorestore = deletedRules.find((rule) => getRowId(rule.grafana_alert) === id); + if (!ruleTorestore) { + return; + } + + setConfirmRestore(true); + setRestoreRule(ruleTorestore); + }; + + const hideConfirmation = () => { + setConfirmRestore(false); + }; + + const columns: Array> = [ + { + id: 'createdBy', + header: t('alerting.deleted-rules.table.updatedBy', 'Deleted By'), + disableGrow: true, + cell: ({ row }) => { + return ; + }, + }, + { + id: 'title', + header: t('alerting.deleted-rules.table.title', 'Title'), + disableGrow: true, + cell: ({ row }) => { + return row.original.grafana_alert.title; + }, + }, + { + id: 'folder', + header: t('alerting.deleted-rules.table.folder', 'Folder'), + disableGrow: true, + cell: ({ row }) => { + return row.original.grafana_alert.namespace_uid; + }, + }, + { + id: 'group', + header: t('alerting.deleted-rules.table.group', 'Group'), + disableGrow: true, + cell: ({ row }) => { + return row.original.grafana_alert.rule_group; + }, + }, + { + id: 'created', + header: t('alerting.deleted-rules.table.updated', 'Deletion Date'), + disableGrow: true, + cell: ({ row }) => { + const value = row.original.grafana_alert.updated; + if (!value) { + return unknown; + } + return dateTimeFormat(value) + ' (' + dateTimeFormatTimeAgo(value) + ')'; + }, + }, + { + id: 'actions', + disableGrow: true, + cell: ({ row }) => { + return ( + + + + ); + }, + }, + ]; + + return ( + <> + { + return getRowId(row.grafana_alert); + }} + /> + + + ); +} + +function getRowId(row: GrafanaRuleDefinition) { + return row.guid || row.uid; +} diff --git a/public/app/features/alerting/unified/components/rules/deleted-rules/DeletedRulesPage.tsx b/public/app/features/alerting/unified/components/rules/deleted-rules/DeletedRulesPage.tsx new file mode 100644 index 00000000000..07f3807ff40 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/deleted-rules/DeletedRulesPage.tsx @@ -0,0 +1,36 @@ +import { Alert } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; + +import { alertRuleApi } from '../../../api/alertRuleApi'; +import { GRAFANA_RULER_CONFIG } from '../../../api/featureDiscoveryApi'; +import { stringifyErrorLike } from '../../../utils/misc'; +import { withPageErrorBoundary } from '../../../withPageErrorBoundary'; +import { AlertingPageWrapper } from '../../AlertingPageWrapper'; + +import { DeletedRules } from './DeletedRules'; + +function DeletedrulesPage() { + const { + currentData = [], + isLoading, + error, + } = alertRuleApi.endpoints.getDeletedRules.useQuery({ + rulerConfig: GRAFANA_RULER_CONFIG, + filter: {}, // todo: add filters, and limit????? + }); + + return ( + + <> + {error && ( + + {stringifyErrorLike(error)} + + )} + + + + ); +} + +export default withPageErrorBoundary(DeletedrulesPage); diff --git a/public/app/features/alerting/unified/components/rules/deleted-rules/useRestoreDeletedRule.ts b/public/app/features/alerting/unified/components/rules/deleted-rules/useRestoreDeletedRule.ts new file mode 100644 index 00000000000..2fa1477c713 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/deleted-rules/useRestoreDeletedRule.ts @@ -0,0 +1,19 @@ +import { useAsync } from 'app/features/alerting/unified/hooks/useAsync'; +import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; +import { GrafanaRuleDefinition, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; + +import { useAddRuleToRuleGroup } from '../../../hooks/ruleGroup/useUpsertRuleFromRuleGroup'; + +export function useRestoreDeletedRule() { + const [addRuleToRuleGroup] = useAddRuleToRuleGroup(); + + return useAsync(async (deletedRule: RulerGrafanaRuleDTO) => { + const ruleGroupIdentifier = { + dataSourceName: GRAFANA_RULES_SOURCE_NAME, + namespaceName: deletedRule.grafana_alert.namespace_uid, + groupName: deletedRule.grafana_alert.rule_group, + }; + // save the new rule to the rule group + return addRuleToRuleGroup.execute(ruleGroupIdentifier, deletedRule); + }); +} diff --git a/public/app/features/alerting/unified/featureToggles.ts b/public/app/features/alerting/unified/featureToggles.ts index 3f35bb2479e..659f4ec703b 100644 --- a/public/app/features/alerting/unified/featureToggles.ts +++ b/public/app/features/alerting/unified/featureToggles.ts @@ -1,6 +1,11 @@ import { config } from '@grafana/runtime'; +import { isAdmin } from './utils/misc'; + export const shouldUsePrometheusRulesPrimary = () => config.featureToggles.alertingPrometheusRulesPrimary ?? false; export const useGrafanaManagedRecordingRulesSupport = () => config.unifiedAlerting.recordingRulesEnabled && config.featureToggles.grafanaManagedRecordingRules; + +export const shouldAllowRecoveringDeletedRules = () => + (isAdmin() && config.featureToggles.alertingRuleRecoverDeleted && config.featureToggles.alertRuleRestore) ?? false; diff --git a/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx index 5c1859b5865..5ee31b47542 100644 --- a/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx @@ -47,7 +47,7 @@ const getPageNav = (identifier?: RuleIdentifier, type?: RuleEditorPathParams['ty const RuleEditor = () => { const { identifier, type } = useRuleEditorPathParams(); - const { copyFromIdentifier, queryDefaults } = useRuleEditorQueryParams(); + const { copyFromIdentifier, queryDefaults, isManualRestore } = useRuleEditorQueryParams(); const { canCreateGrafanaRules, canCreateCloudRules, canEditRules } = useRulesAccess(); @@ -68,8 +68,16 @@ const RuleEditor = () => { return ; } // new alert rule - return ; - }, [canCreateCloudRules, canCreateGrafanaRules, canEditRules, copyFromIdentifier, identifier, queryDefaults]); + return ; + }, [ + canCreateCloudRules, + canCreateGrafanaRules, + canEditRules, + copyFromIdentifier, + identifier, + queryDefaults, + isManualRestore, + ]); return ( @@ -97,6 +105,7 @@ function useRuleEditorQueryParams() { const [searchParams] = useURLSearchParams(); const copyFromId = searchParams.get('copyFrom') ?? undefined; const copyFromIdentifier = ruleId.tryParse(copyFromId); + const isManualRestore = searchParams.has('isManualRestore'); const ruleType = translateRouteParamToRuleType(type); @@ -104,5 +113,5 @@ function useRuleEditorQueryParams() { ? formValuesFromQueryParams(searchParams.get('defaults') ?? '', ruleType) : undefined; - return { copyFromIdentifier, queryDefaults }; + return { copyFromIdentifier, queryDefaults, isManualRestore }; } diff --git a/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx index 94888d4c9b1..9505c96fd23 100644 --- a/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx @@ -7,12 +7,16 @@ import { byRole } from 'testing-library-selector'; import { contextSrv } from 'app/core/services/context_srv'; import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import { PROMETHEUS_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants'; +import { DashboardSearchItemType } from 'app/features/search/types'; import { AccessControlAction } from 'app/types'; -import { grantUserPermissions, mockDataSource } from '../mocks'; -import { grafanaRulerGroup } from '../mocks/grafanaRulerApi'; +import { grantUserPermissions, mockDataSource, mockFolder } from '../mocks'; +import { grafanaRulerGroup, grafanaRulerRule } from '../mocks/grafanaRulerApi'; +import { setFolderResponse } from '../mocks/server/configure'; import { captureRequests, serializeRequests } from '../mocks/server/events'; import { setupDataSources } from '../testSetup/datasources'; +import { Annotation } from '../utils/constants'; +import { grafanaRuleDtoToFormValues } from '../utils/rule-form'; jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) =>
{actions}
, @@ -104,4 +108,36 @@ describe('RuleEditor grafana managed rules', () => { await user.click(ui.buttons.preview.get()); expect(await screen.findByText(/you cannot use time series data as an alert condition/i)).toBeInTheDocument(); }); + it('can restore grafana managed alert when isManualRestore is passed as query param', 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 capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/')); + const grafanaRuleJson = JSON.stringify(grafanaRuleDtoToFormValues(grafanaRulerRule, folder.title)); + + const { user } = renderRuleEditor(undefined, undefined, grafanaRuleJson); // isManualRestore=true + + // check that it's filled in + const nameInput = await ui.inputs.name.find(); + expect(nameInput).toHaveValue(grafanaRulerRule.grafana_alert.title); + //check that folder is in the list + expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title)); + expect(ui.inputs.annotationValue(0).get()).toHaveValue(grafanaRulerRule.annotations[Annotation.summary]); + + expect(ui.manualRestoreBanner.get()).toBeInTheDocument(); // check that manual restore banner is shown + + await user.click(ui.buttons.saveAndExit.get()); + + expect(await screen.findByRole('status')).toHaveTextContent('Rule added successfully'); + const requests = await capture; + const serializedRequests = await serializeRequests(requests); + expect(serializedRequests).toMatchSnapshot(); + }); }); diff --git a/public/app/features/alerting/unified/rule-editor/__snapshots__/RuleEditorGrafanaRules.test.tsx.snap b/public/app/features/alerting/unified/rule-editor/__snapshots__/RuleEditorGrafanaRules.test.tsx.snap index 821c074014f..692484862f3 100644 --- a/public/app/features/alerting/unified/rule-editor/__snapshots__/RuleEditorGrafanaRules.test.tsx.snap +++ b/public/app/features/alerting/unified/rule-editor/__snapshots__/RuleEditorGrafanaRules.test.tsx.snap @@ -166,3 +166,113 @@ exports[`RuleEditor grafana managed rules can create new grafana managed alert 1 }, ] `; + +exports[`RuleEditor grafana managed rules can restore grafana managed alert when isManualRestore is passed as query param 1`] = ` +[ + { + "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": false, + "namespace_uid": "uuid020c61ef", + "no_data_state": "NoData", + "rule_group": "grafana-group-1", + "title": "Grafana-rule", + "uid": "4d7125fee983", + }, + "labels": { + "region": "nasa", + "severity": "critical", + }, + }, + { + "annotations": { + "summary": "Test alert", + }, + "for": "5m", + "grafana_alert": { + "condition": "A", + "data": [ + { + "datasourceUid": "datasource-uid", + "model": { + "datasource": { + "type": "prometheus", + "uid": "datasource-uid", + }, + "expression": "vector(1)", + "instant": true, + "queryType": "alerting", + "range": false, + "refId": "A", + }, + "queryType": "alerting", + "refId": "A", + "relativeTimeRange": { + "from": 1000, + "to": 2000, + }, + }, + ], + "exec_err_state": "Error", + "is_paused": false, + "metadata": { + "editor_settings": { + "simplified_notifications_section": true, + "simplified_query_and_expressions_section": false, + }, + }, + "no_data_state": "NoData", + "title": "Grafana-rule", + }, + "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", + }, +] +`; diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index fcf9ee3d77e..d89c976bf39 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -32,6 +32,7 @@ import { Labels, PostableRuleGrafanaRuleDTO, RulerAlertingRuleDTO, + RulerGrafanaRuleDTO, RulerRecordingRuleDTO, RulerRuleDTO, } from 'app/types/unified-alerting-dto'; @@ -365,6 +366,56 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF } } +export function grafanaRuleDtoToFormValues(rule: RulerGrafanaRuleDTO, namespace: string): RuleFormValues { + const defaultFormValues = getDefaultFormValues(); + + const ga = rule.grafana_alert; + const duration = rule.for; + const annotations = rule.annotations; + const labels = rule.labels; + + const commonProperties = { + ...defaultFormValues, + name: ga.title, + queries: ga.data, + condition: ga.condition, + annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(annotations, false)), + labels: listifyLabelsOrAnnotations(labels, true), + folder: { title: namespace, uid: ga.namespace_uid }, + isPaused: ga.is_paused, + }; + + if (rulerRuleType.grafana.recordingRule(rule)) { + // grafana recording rule + return { + ...commonProperties, + type: RuleFormType.grafanaRecording, + group: ga.rule_group, + metric: ga.record?.metric, + }; + } + + // grafana alerting rule + const routingSettings: AlertManagerManualRouting | undefined = getContactPointsFromDTO(ga); + if (ga.no_data_state !== undefined && ga.exec_err_state !== undefined) { + return { + ...commonProperties, + type: RuleFormType.grafana, + group: ga.rule_group, + evaluateFor: duration || '0', + noDataState: ga.no_data_state, + execErrState: ga.exec_err_state, + + contactPoints: routingSettings, + manualRouting: Boolean(routingSettings), + + editorSettings: getEditorSettingsFromDTO(ga), + }; + } else { + throw new Error('Unexpected type of rule for grafana rules source'); + } +} + export function alertingRulerRuleToRuleForm( rule: RulerAlertingRuleDTO ): Pick< diff --git a/public/app/features/connections/__mocks__/store.navIndex.mock.ts b/public/app/features/connections/__mocks__/store.navIndex.mock.ts index 125090b00d4..c4cb2abca35 100644 --- a/public/app/features/connections/__mocks__/store.navIndex.mock.ts +++ b/public/app/features/connections/__mocks__/store.navIndex.mock.ts @@ -170,6 +170,12 @@ export const navIndex: NavIndex = { url: '/alerting/new', hideFromTabs: true, }, + { + id: 'alerts/recently-deleted', + text: 'Recently deleted', + icon: 'trash-alt', + url: '/alerting/recently-deleted', + }, ], parentItem: { id: 'home', diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index 495c034028b..de7a68ac1aa 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -270,6 +270,7 @@ export interface PostableGrafanaRuleDefinition { export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition { id?: string; uid: string; + guid?: string; namespace_uid: string; rule_group: string; provenance?: string; @@ -312,4 +313,8 @@ export type RulerRuleGroupDTO = { export type PostableRulerRuleGroupDTO = RulerRuleGroupDTO; +export type RulerGrafanaRuleGroupDTO = RulerRuleGroupDTO; + export type RulerRulesConfigDTO = { [namespace: string]: RulerRuleGroupDTO[] }; + +export type RulerGrafanaRulesConfigDTO = { [namespace: string]: RulerGrafanaRuleGroupDTO[] }; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 9603556bc66..858e1b19f60 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -299,6 +299,13 @@ "state-no-data": "Alert state if no data or all values are null", "target-datasource-uid": "Target data source" }, + "alert-menu": { + "copy-link": "Copy link", + "duplicate": "Duplicate", + "export": "Export", + "silence-notifications": "Silence notifications", + "with-modifications": "With modifications" + }, "alert-recording-rule-form": { "evaluation-behaviour": { "description": { @@ -465,6 +472,31 @@ "missing-reference": "Expression \"{{source}}\" failed to run because \"{{target}}\" is missing or also failed.", "self-reference": "You can't link an expression to itself" }, + "delete-rule-modal": { + "title": "Delete rule", + "with-soft-delete": "Are you sure you want to delete this rule? This rule will be recoverable from the Recently deleted page by a user with an admin role.", + "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": { + "empty-state-title": "No recently deleted rules found", + "errorloading": "Failed to load alert deleted rules", + "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": { + "body": "Are you sure you want to restore this deleted alert rule definition?", + "confirm": "Yes, restore deleted rule", + "error": "Could not restore deleted alert rule", + "title": "Restore deleted alert rule" + }, + "table": { + "folder": "Folder", + "group": "Group", + "title": "Title", + "updated": "Deletion Date", + "updatedBy": "Deleted By" + }, + "unknown": "Unknown" + }, "draggable-rules-table": { "evals-to-start-alerting": "Evaluations to start alerting", "pending-period": "Pending period", @@ -3706,6 +3738,10 @@ "subtitle": "Alerting and incident management apps", "title": "Alerts & IRM" }, + "alerts-recently-deleted": { + "subtitle": "See recently deleted alert rules", + "title": "Recently deleted" + }, "api-keys": { "subtitle": "Manage and create API keys that are used to interact with Grafana HTTP APIs", "title": "API keys" diff --git a/public/test/helpers/alertingRuleEditor.tsx b/public/test/helpers/alertingRuleEditor.tsx index 4b50dce768e..d15eee0a08b 100644 --- a/public/test/helpers/alertingRuleEditor.tsx +++ b/public/test/helpers/alertingRuleEditor.tsx @@ -13,6 +13,7 @@ export enum GrafanaRuleFormStep { export const ui = { loadingIndicator: byText('Loading rule...'), + manualRestoreBanner: byText(/restoring rule manually/i), inputs: { name: byRole('textbox', { name: 'name' }), metric: byRole('textbox', { name: 'metric' }), @@ -45,7 +46,17 @@ export const ui = { preview: byRole('button', { name: /^Preview$/ }), }, }; -export function renderRuleEditor(identifier?: string, recording?: 'recording' | 'grafana-recording') { +export function renderRuleEditor( + identifier?: string, + recording?: 'recording' | 'grafana-recording', + restoreFrom?: string +) { + const isManualRestore = Boolean(restoreFrom); + const restoreFromEncoded = restoreFrom ? encodeURIComponent(restoreFrom) : ''; + const newAlertRuleRoute = + `/alerting/new/${recording ?? 'alerting'}` + + (isManualRestore ? `?isManualRestore=true&defaults=${restoreFromEncoded}` : ''); + const initialEntries = [identifier ? `/alerting/${identifier}/edit` : newAlertRuleRoute]; return render( <> @@ -56,7 +67,7 @@ export function renderRuleEditor(identifier?: string, recording?: 'recording' | , { historyOptions: { - initialEntries: [identifier ? `/alerting/${identifier}/edit` : `/alerting/new/${recording ?? 'alerting'}`], + initialEntries: initialEntries, }, } );