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:
co-authored by
Yuri Tseretyan
Tom Ratcliffe
parent
2489e423d0
commit
f9471ac10b
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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];
|
||||
|
||||
+167
@@ -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);
|
||||
};
|
||||
+53
@@ -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;
|
||||
}
|
||||
+36
@@ -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);
|
||||
+19
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+110
@@ -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',
|
||||
|
||||
@@ -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[] };
|
||||
|
||||
@@ -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,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,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user