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 <yuriy.tseretyan@grafana.com>
Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
This commit is contained in:
Sonia Aguilar
2025-03-27 16:39:26 +02:00
committed by GitHub
co-authored by Yuri Tseretyan Tom Ratcliffe
parent 2489e423d0
commit f9471ac10b
28 changed files with 833 additions and 27 deletions
-8
View File
@@ -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 <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> 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 <Trans /> or use t()", "0"]
],
@@ -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;
}
+10
View File
@@ -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
},
}
)
+1
View File
@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
238 alertingMigrationUI experimental @grafana/alerting-squad false false true
239 unifiedStorageHistoryPruner experimental @grafana/search-and-storage false false false
240 unifiedStorageGrpcConnectionPool experimental @grafana/search-and-storage false false false
241 alertingRuleRecoverDeleted GA @grafana/alerting-squad false false true
+4
View File
@@ -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"
)
+19
View File
@@ -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",
@@ -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{
@@ -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':
+15
View File
@@ -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'
)
)
: () => <Navigate replace to="/alerting/list" />,
},
{
path: '/alerting/new/:type?',
pageClass: 'page-alerting',
@@ -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';
@@ -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<Array<RulerGrafanaRuleDTO<GrafanaRuleDefinition>>, {}>({
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'],
}),
}),
});
@@ -129,6 +129,7 @@ export const alertingApi = createApi({
'ContactPoint',
'ContactPointsStatus',
'Receiver',
'DeletedRules',
],
endpoints: () => ({}),
});
@@ -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' && (
<MenuItemPauseRule rule={rulerRule} groupIdentifier={groupIdentifier} onPauseChange={onPauseChange} />
)}
{canSilence && <Menu.Item label="Silence notifications" icon="bell-slash" onClick={handleSilence} />}
{canSilence && (
<Menu.Item
label={t('alerting.alert-menu.silence-notifications', 'Silence notifications')}
icon="bell-slash"
onClick={handleSilence}
/>
)}
{/* TODO Migrate Declare Incident to plugin links extensions */}
{shouldShowDeclareIncidentButton && <DeclareIncidentMenuItem title={promRule.name} url={''} />}
{canDuplicate && <Menu.Item label="Duplicate" icon="copy" onClick={() => handleDuplicateRule(identifier)} />}
{canDuplicate && (
<Menu.Item
label={t('alerting.alert-menu.duplicate', 'Duplicate')}
icon="copy"
onClick={() => handleDuplicateRule(identifier)}
/>
)}
{showDivider && <Menu.Divider />}
{shareUrl && <Menu.Item label="Copy link" icon="share-alt" onClick={() => copyToClipboard(shareUrl)} />}
{shareUrl && (
<Menu.Item
label={t('alerting.alert-menu.copy-link', 'Copy link')}
icon="share-alt"
onClick={() => copyToClipboard(shareUrl)}
/>
)}
{canExport && (
<Menu.Item
label="Export"
label={t('alerting.alert-menu.export', 'Export')}
icon="download-alt"
childItems={[<ExportMenuItem key="export-with-modifications" identifier={identifier} />]}
/>
@@ -114,7 +133,7 @@ const AlertRuleMenu = ({
<>
<Menu.Divider />
<Menu.Item
label="Delete"
label={t('alerting.common.delete', 'Delete')}
icon="trash-alt"
destructive
onClick={() => handleDelete(rulerRule, groupIdentifier)}
@@ -144,7 +163,14 @@ const ExportMenuItem = ({ identifier }: ExportMenuItemProps) => {
}
);
return <Menu.Item key="with-modifications" label="With modifications" icon="file-edit-alt" url={url} />;
return (
<Menu.Item
key="with-modifications"
label={t('alerting.alert-menu.with-modifications', 'With modifications')}
icon="file-edit-alt"
url={url}
/>
);
};
function copyToClipboard(text: string) {
@@ -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<DeleteRuleInfo>();
const [deleteRuleFromGroup] = useDeleteRuleFromGroup();
const { waitForRemoval } = usePrometheusConsistencyCheck();
const isSoftDeleteEnabled = shouldAllowRecoveringDeletedRules();
const dismissModal = useCallback(() => {
setRuleToDelete(undefined);
@@ -68,15 +70,25 @@ export const useDeleteModal = (redirectToListView = false): DeleteModalHook => {
() => (
<ConfirmModal
isOpen={Boolean(ruleToDelete)}
title="Delete rule"
body="Deleting this rule will permanently remove it from your alert rule list. Are you sure you want to delete this rule?"
title={t('alerting.delete-rule-modal.title', 'Delete rule')}
body={
isSoftDeleteEnabled
? t(
'alerting.delete-rule-modal.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.'
)
: t(
'alerting.delete-rule-modal.without-soft-delete',
'Deleting this rule will permanently remove it from your alert rule list. Are you sure you want to delete this rule?'
)
}
confirmText="Yes, delete"
icon="exclamation-triangle"
onConfirm={deleteRule}
onDismiss={dismissModal}
/>
),
[ruleToDelete, deleteRule, dismissModal]
[ruleToDelete, deleteRule, dismissModal, isSoftDeleteEnabled]
);
return [modal, showModal, dismissModal];
@@ -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<ComponentProps<typeof ConfirmModal>, '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 (
<ConfirmModal
isOpen={isOpen}
title={title}
confirmText={confirmText}
modalClass={styles.modal}
confirmButtonVariant={!error ? 'destructive' : 'primary'}
body={
<Stack direction="column" gap={2}>
<Trans i18nKey="alerting.deleted-rules.restore-modal.body">
Are you sure you want to restore this deleted alert rule definition?
</Trans>
<div>{ruleToRestore && <RulePreview rule={ruleToRestore} />}</div>
{error && (
<Alert severity="warning" title={errorTitle}>
<Trans i18nKey="alerting.deleted-rules.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.
</Trans>
<pre style={{ marginBottom: 0 }}>
<code>{stringifyErrorLike(error)}</code>
</pre>
</Alert>
)}
</Stack>
}
onConfirm={!error ? onRestoreConfirm : onManualRestore}
onDismiss={onDismiss}
/>
);
};
function RulePreview({ rule }: { rule: RulerRuleDTO }) {
const styles = useStyles2(getStyles);
return (
<div className={styles.content}>
<CodeEditor
width="100%"
height={600}
language={'json'}
value={JSON.stringify(rule, null, 4)}
monacoOptions={{
minimap: {
enabled: false,
},
scrollBeyondLastLine: false,
lineNumbers: 'on',
readOnly: true,
}}
/>
</div>
);
}
const getStyles = () => ({
content: css({
flex: '1 1 100%',
}),
modal: css({
width: '700px',
}),
});
export function useRestoreDeletedRule() {
const [addRuleToRuleGroup] = useAddRuleToRuleGroup();
return useAsync(async (deletedRule: RulerGrafanaRuleDTO<GrafanaRuleDefinition>) => {
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<RuleFormValues> | 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);
};
@@ -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(
<>
<AppNotificationList />
<DeletedRules deletedRules={[grafanaRulerRule]} />
</>
);
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');
});
});
@@ -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<RulerGrafanaRuleDTO<GrafanaRuleDefinition>>;
}
export function DeletedRules({ deletedRules }: DeletedRulesProps) {
const [confirmRestore, setConfirmRestore] = useState(false);
const [restoreRule, setRestoreRule] = useState<RulerGrafanaRuleDTO | undefined>();
const unknown = t('alerting.deleted-rules.unknown', 'Unknown');
if (deletedRules.length === 0) {
return (
<EmptyState
message={t('alerting.deleted-rules.empty-state-title', 'No recently deleted rules found')}
variant="not-found"
/>
);
}
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<Column<(typeof deletedRules)[0]>> = [
{
id: 'createdBy',
header: t('alerting.deleted-rules.table.updatedBy', 'Deleted By'),
disableGrow: true,
cell: ({ row }) => {
return <UpdatedByUser user={row.original.grafana_alert.updated_by} />;
},
},
{
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 (
<Stack direction="row" alignItems="center" justifyContent="flex-end">
<Button
variant="secondary"
size="sm"
icon="history"
onClick={() => {
showConfirmation(getRowId(row.original.grafana_alert));
}}
>
<Trans i18nKey="alerting.deleted-rules.restore">Restore</Trans>
</Button>
</Stack>
);
},
},
];
return (
<>
<InteractiveTable
pageSize={DELETED_RULES_PAGE_SIZE}
columns={columns}
data={deletedRules}
getRowId={(row) => {
return getRowId(row.grafana_alert);
}}
/>
<ConfirmRestoreDeletedRuleModal
ruleToRestore={restoreRule}
isOpen={confirmRestore}
onDismiss={hideConfirmation}
onRestoreSucess={trackDeletedRuleRestoreSuccess}
onRestoreError={trackDeletedRuleRestoreFail}
/>
</>
);
}
function getRowId(row: GrafanaRuleDefinition) {
return row.guid || row.uid;
}
@@ -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 (
<AlertingPageWrapper navId="alerts/recently-deleted" isLoading={isLoading}>
<>
{error && (
<Alert title={t('alerting.deleted-rules.errorloading', 'Failed to load alert deleted rules')}>
{stringifyErrorLike(error)}
</Alert>
)}
<DeletedRules deletedRules={currentData} />
</>
</AlertingPageWrapper>
);
}
export default withPageErrorBoundary(DeletedrulesPage);
@@ -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<GrafanaRuleDefinition>) => {
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);
});
}
@@ -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;
@@ -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 <CloneRuleEditor sourceRuleId={copyFromIdentifier} />;
}
// new alert rule
return <AlertRuleForm prefill={queryDefaults} />;
}, [canCreateCloudRules, canCreateGrafanaRules, canEditRules, copyFromIdentifier, identifier, queryDefaults]);
return <AlertRuleForm prefill={queryDefaults} isManualRestore={isManualRestore} />;
}, [
canCreateCloudRules,
canCreateGrafanaRules,
canEditRules,
copyFromIdentifier,
identifier,
queryDefaults,
isManualRestore,
]);
return (
<AlertingPageWrapper navId="alert-list" pageNav={getPageNav(identifier, type)}>
@@ -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 };
}
@@ -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 }) => <div>{actions}</div>,
@@ -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();
});
});
@@ -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",
},
]
`;
@@ -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<
@@ -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',
+5
View File
@@ -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<R = RulerRuleDTO> = {
export type PostableRulerRuleGroupDTO = RulerRuleGroupDTO<PostableRuleDTO>;
export type RulerGrafanaRuleGroupDTO = RulerRuleGroupDTO<RulerGrafanaRuleDTO>;
export type RulerRulesConfigDTO = { [namespace: string]: RulerRuleGroupDTO[] };
export type RulerGrafanaRulesConfigDTO = { [namespace: string]: RulerGrafanaRuleGroupDTO[] };
+36
View File
@@ -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"
+13 -2
View File
@@ -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(
<>
<AppNotificationList />
@@ -56,7 +67,7 @@ export function renderRuleEditor(identifier?: string, recording?: 'recording' |
</>,
{
historyOptions: {
initialEntries: [identifier ? `/alerting/${identifier}/edit` : `/alerting/new/${recording ?? 'alerting'}`],
initialEntries: initialEntries,
},
}
);