mirror of https://github.com/grafana/grafana.git
Alerting: Rule history restore feature (#100609)
* Restore feature: wip * Refactor modal to separate component * fix restoring from the drawer * rename components folder to version-history, and move version-utils.file there * skip fetching rule when uid is empty, add returnTo when restoring manually * Fix drawer fetching infinitely * Move drawer to separate file * add tracking for restore success and restore failure * Fix name of error interaction * Add `compare` to each row in version history * Add warning when manually restoring and trigger form validation * Fix initial validation for contact point selector * Wait for successful fetch before potential error * Add disabled state when loading * Fix loading check for contact point selector * Fix typo * Move hook to separate file and move other method into utils * Update imports and remove manual state management * Fix infinite render * Remove onError from dep array * Use separate flag for showing manual restore alert * Rename to createdAt * add and use ability to restore to check if retore is allowed * Fix test and add isGrafanaManagedAlertRule to the ability check * Address PR feedback * Change to isManualRestore for trigger check * udpate AlertRuleAction.Restore ability * make the alertingRuleVersionHistoryRestore ff , enabled by default * fix ff --------- Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
This commit is contained in:
parent
ae2074ef55
commit
e73b78a134
|
|
@ -1752,10 +1752,11 @@ var (
|
|||
Name: "alertingRuleVersionHistoryRestore",
|
||||
Description: "Enables the alert rule version history restore feature",
|
||||
FrontendOnly: true,
|
||||
Stage: FeatureStageExperimental,
|
||||
Stage: FeatureStageGeneralAvailability,
|
||||
Owner: grafanaAlertingSquad,
|
||||
HideFromAdminPage: true,
|
||||
HideFromDocs: true,
|
||||
Expression: "true", // enabled by default
|
||||
},
|
||||
{
|
||||
Name: "newShareReportDrawer",
|
||||
|
|
|
|||
|
|
@ -232,6 +232,6 @@ newLogsPanel,experimental,@grafana/observability-logs,false,false,true
|
|||
grafanaconThemes,experimental,@grafana/grafana-frontend-platform,false,true,false
|
||||
pluginsCDNSyncLoader,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||
alertingJiraIntegration,experimental,@grafana/alerting-squad,false,false,true
|
||||
alertingRuleVersionHistoryRestore,experimental,@grafana/alerting-squad,false,false,true
|
||||
alertingRuleVersionHistoryRestore,GA,@grafana/alerting-squad,false,false,true
|
||||
newShareReportDrawer,experimental,@grafana/sharing-squad,false,false,false
|
||||
rendererDisableAppPluginsPreload,experimental,@grafana/sharing-squad,false,false,true
|
||||
|
|
|
|||
|
|
|
@ -405,19 +405,20 @@
|
|||
{
|
||||
"metadata": {
|
||||
"name": "alertingRuleVersionHistoryRestore",
|
||||
"resourceVersion": "1738831836776",
|
||||
"resourceVersion": "1740740039764",
|
||||
"creationTimestamp": "2025-01-16T14:08:12Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-02-06 08:50:36.776739 +0000 UTC"
|
||||
"grafana.app/updatedTimestamp": "2025-02-28 10:53:59.764894 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the alert rule version history restore feature",
|
||||
"stage": "experimental",
|
||||
"stage": "GA",
|
||||
"codeowner": "@grafana/alerting-squad",
|
||||
"frontend": true,
|
||||
"hideFromAdminPage": true,
|
||||
"hideFromDocs": true
|
||||
"hideFromDocs": true,
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { jsonDiff } from 'app/features/dashboard-scene/settings/version-history/
|
|||
export interface RevisionModel {
|
||||
version: number | string;
|
||||
/** When was this version created? */
|
||||
created: string;
|
||||
createdAt: string;
|
||||
/** Who created/edited this version? */
|
||||
createdBy: string;
|
||||
/** Optional message describing change encapsulated in this version */
|
||||
|
|
@ -36,11 +36,17 @@ type DiffViewProps<T extends DiffArgument> = {
|
|||
* e.g. mapping machine IDs to translated names, removing fields that the user can't control anyway etc.
|
||||
*/
|
||||
preprocessVersion?: (version: T) => DiffArgument;
|
||||
/**
|
||||
* Should we show the restore button?
|
||||
*/
|
||||
showRestoreButton?: boolean;
|
||||
/** Method invoked when restore button is clicked */
|
||||
onRestore: () => void;
|
||||
};
|
||||
|
||||
const VersionChangeSummary = ({ info }: { info: RevisionModel }) => {
|
||||
const { created, createdBy, version, message = '' } = info;
|
||||
const ageString = dateTimeFormatTimeAgo(created);
|
||||
const { createdAt, createdBy, version, message = '' } = info;
|
||||
const ageString = dateTimeFormatTimeAgo(createdAt);
|
||||
return (
|
||||
<Trans i18nKey="core.versionHistory.comparison.header.text">
|
||||
Version {{ version }} updated by {{ createdBy }} ({{ ageString }}) {{ message }}
|
||||
|
|
@ -54,6 +60,8 @@ export const VersionHistoryComparison = <T extends DiffArgument>({
|
|||
oldVersion,
|
||||
newVersion,
|
||||
preprocessVersion = identity,
|
||||
showRestoreButton = false,
|
||||
onRestore,
|
||||
}: DiffViewProps<T>) => {
|
||||
const diff = jsonDiff(preprocessVersion(oldVersion), preprocessVersion(newVersion));
|
||||
const noHumanReadableDiffs = Object.entries(diff).length === 0;
|
||||
|
|
@ -83,18 +91,28 @@ export const VersionHistoryComparison = <T extends DiffArgument>({
|
|||
))}
|
||||
<Divider />
|
||||
</Box>
|
||||
<Box>
|
||||
{showJsonDiff && (
|
||||
<Button variant="secondary" onClick={() => setShowJsonDiff(false)}>
|
||||
<Stack gap={2} direction="row" justifyContent="space-between">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
showJsonDiff ? setShowJsonDiff(false) : setShowJsonDiff(true);
|
||||
}}
|
||||
>
|
||||
{showJsonDiff && (
|
||||
<Trans i18nKey="core.versionHistory.comparison.header.hide-json-diff">Hide JSON diff </Trans>
|
||||
</Button>
|
||||
)}
|
||||
{!showJsonDiff && (
|
||||
<Button variant="secondary" onClick={() => setShowJsonDiff(true)}>
|
||||
)}
|
||||
{!showJsonDiff && (
|
||||
<Trans i18nKey="core.versionHistory.comparison.header.show-json-diff">Show JSON diff </Trans>
|
||||
)}
|
||||
</Button>
|
||||
{showRestoreButton && (
|
||||
<Button variant="destructive" onClick={onRestore} icon="history">
|
||||
<Trans i18nKey="alerting.alertVersionHistory.restore-version">
|
||||
Restore to version {{ version: oldSummary.version }}
|
||||
</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
{showJsonDiff && (
|
||||
<DiffViewer oldValue={JSON.stringify(oldVersion, null, 2)} newValue={JSON.stringify(newVersion, null, 2)} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { contextSrv } from 'app/core/core';
|
|||
import { RuleNamespace } from '../../../types/unified-alerting';
|
||||
import { RulerRulesConfigDTO } from '../../../types/unified-alerting-dto';
|
||||
|
||||
import { Origin } from './components/rule-viewer/tabs/version-history/ConfirmVersionRestoreModal';
|
||||
import { FilterType } from './components/rules/central-state-history/EventListSceneObject';
|
||||
import { RulesFilter, getSearchFilterFromQuery } from './search/rulesSearchParser';
|
||||
import { RuleFormType } from './types/rule-form';
|
||||
|
|
@ -229,6 +230,16 @@ export const trackRuleVersionsComparisonClick = async (payload: RuleVersionCompa
|
|||
reportInteraction('grafana_alerting_rule_versions_comparison_click', { ...payload });
|
||||
};
|
||||
|
||||
export const trackRuleVersionsRestoreSuccess = async (payload: RuleVersionComparisonProps & { origin: Origin }) => {
|
||||
reportInteraction('grafana_alerting_rule_versions_restore_success', { ...payload });
|
||||
};
|
||||
|
||||
export const trackRuleVersionsRestoreFail = async (
|
||||
payload: RuleVersionComparisonProps & { origin: Origin; error: Error }
|
||||
) => {
|
||||
reportInteraction('grafana_alerting_rule_versions_restore_error', { ...payload });
|
||||
};
|
||||
|
||||
interface RulesSearchInteractionPayload {
|
||||
filter: string;
|
||||
triggeredBy: 'typing' | 'component';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { css, cx, keyframes } from '@emotion/css';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { Alert, IconButton, Select, SelectCommonProps, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
|
|
@ -20,12 +20,14 @@ type ContactPointSelectorProps = {
|
|||
showRefreshButton?: boolean;
|
||||
/** Name of a contact point to optionally find and set as the preset value on the dropdown */
|
||||
selectedContactPointName?: string | null;
|
||||
onError?: (error: Error) => void;
|
||||
};
|
||||
|
||||
export const ContactPointSelector = ({
|
||||
selectProps,
|
||||
showRefreshButton,
|
||||
selectedContactPointName,
|
||||
onError = () => {},
|
||||
}: ContactPointSelectorProps) => {
|
||||
const { selectedAlertmanager } = useAlertmanager();
|
||||
const { contactPoints, isLoading, error, refetch } = useContactPointsWithStatus({
|
||||
|
|
@ -58,6 +60,13 @@ export const ContactPointSelector = ({
|
|||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// If the contact points are fetched successfully and the selected contact point is not in the list, show an error
|
||||
if (!isLoading && selectedContactPointName && !matchedContactPoint) {
|
||||
onError(new Error(`Contact point "${selectedContactPointName}" could not be found`));
|
||||
}
|
||||
}, [isLoading, matchedContactPoint, onError, selectedContactPointName]);
|
||||
|
||||
// TODO error handling
|
||||
if (error) {
|
||||
return <Alert title="Failed to fetch contact points" severity="error" />;
|
||||
|
|
@ -71,6 +80,7 @@ export const ContactPointSelector = ({
|
|||
value={matchedContactPoint}
|
||||
{...selectProps}
|
||||
isLoading={isLoading}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{showRefreshButton && (
|
||||
<IconButton
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { css } from '@emotion/css';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FormProvider, SubmitErrorHandler, UseFormWatch, useForm } from 'react-hook-form';
|
||||
import { useParams } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import { Button, ConfirmModal, Spinner, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { Alert, Button, ConfirmModal, Spinner, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import InfoPausedRule from 'app/features/alerting/unified/components/InfoPausedRule';
|
||||
import {
|
||||
getRuleGroupLocationFromFormValues,
|
||||
|
|
@ -74,11 +74,12 @@ import { QueryAndExpressionsStep } from '../query-and-alert-condition/QueryAndEx
|
|||
type Props = {
|
||||
existing?: RuleWithLocation;
|
||||
prefill?: Partial<RuleFormValues>; // Existing implies we modify existing rule. Prefill only provides default form values
|
||||
isManualRestore?: boolean;
|
||||
};
|
||||
|
||||
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
|
||||
|
||||
export const AlertRuleForm = ({ existing, prefill }: Props) => {
|
||||
export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const notifyApp = useAppNotification();
|
||||
const [showEditYaml, setShowEditYaml] = useState(false);
|
||||
|
|
@ -96,6 +97,11 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
|
|||
const [showDeleteModal, setShowDeleteModal] = useState<boolean>(false);
|
||||
|
||||
const defaultValues: RuleFormValues = useMemo(() => {
|
||||
// If we have an existing AND a prefill, then we're coming from the restore dialog
|
||||
// and we want to merge the two
|
||||
if (existing && prefill) {
|
||||
return { ...formValuesFromExistingRule(existing), ...formValuesFromPrefill(prefill) };
|
||||
}
|
||||
if (existing) {
|
||||
return formValuesFromExistingRule(existing);
|
||||
}
|
||||
|
|
@ -119,8 +125,16 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
|
|||
handleSubmit,
|
||||
watch,
|
||||
formState: { isSubmitting },
|
||||
trigger,
|
||||
} = formAPI;
|
||||
|
||||
useEffect(() => {
|
||||
// If the user is manually restoring an old version of a rule,
|
||||
// we should trigger validation on the form so any problem areas are clearly highlighted for them to action
|
||||
if (isManualRestore) {
|
||||
trigger();
|
||||
}
|
||||
}, [isManualRestore, trigger]);
|
||||
const type = watch('type');
|
||||
const grafanaTypeRule = isGrafanaManagedRuleByType(type ?? RuleFormType.grafana);
|
||||
|
||||
|
|
@ -290,6 +304,17 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
|
|||
<AppChromeUpdate actions={actionButtons} />
|
||||
<form onSubmit={(e) => e.preventDefault()} className={styles.form}>
|
||||
<div className={styles.contentOuter}>
|
||||
{isManualRestore && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title={t('alerting.alertVersionHistory.warning-restore-manually-title', 'Restoring rule manually')}
|
||||
>
|
||||
<Trans i18nKey="alerting.alertVersionHistory.warning-restore-manually">
|
||||
You are manually restoring an old version of this alert rule. Please review the changes carefully before
|
||||
saving the rule definition.
|
||||
</Trans>
|
||||
</Alert>
|
||||
)}
|
||||
{isPaused && <InfoPausedRule />}
|
||||
<Stack direction="column" gap={3}>
|
||||
{/* Step 1 */}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,20 @@ export interface ContactPointSelectorProps {
|
|||
}
|
||||
|
||||
export function ContactPointSelector({ alertManager, onSelectContactPoint }: ContactPointSelectorProps) {
|
||||
const { control, watch, trigger } = useFormContext<RuleFormValues>();
|
||||
const { control, watch, trigger, setError } = useFormContext<RuleFormValues>();
|
||||
|
||||
const contactPointInForm = watch(`contactPoints.${alertManager}.selectedContactPoint`);
|
||||
|
||||
// Wrap in useCallback to avoid infinite render loop
|
||||
const handleError = useCallback(
|
||||
(err: Error) => {
|
||||
setError(`contactPoints.${alertManager}.selectedContactPoint`, {
|
||||
message: err.message,
|
||||
});
|
||||
},
|
||||
[alertManager, setError]
|
||||
);
|
||||
|
||||
// if we have a contact point selected, check if it still exists in the event that someone has deleted it
|
||||
const validateContactPoint = useCallback(() => {
|
||||
if (contactPointInForm) {
|
||||
|
|
@ -49,6 +59,7 @@ export function ContactPointSelector({ alertManager, onSelectContactPoint }: Con
|
|||
}}
|
||||
showRefreshButton
|
||||
selectedContactPointName={contactPointInForm}
|
||||
onError={handleError}
|
||||
/>
|
||||
<LinkToContactPoints />
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ const RuleViewer = () => {
|
|||
{activeTab === ActiveTab.Routing && <Routing />}
|
||||
{activeTab === ActiveTab.Details && <Details rule={rule} />}
|
||||
{activeTab === ActiveTab.VersionHistory && isGrafanaRulerRule(rule.rulerRule) && (
|
||||
<AlertVersionHistory ruleUid={rule.rulerRule.grafana_alert.uid} />
|
||||
<AlertVersionHistory rule={rule.rulerRule} />
|
||||
)}
|
||||
</TabContent>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,31 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Alert, Box, Button, Drawer, EmptyState, LoadingPlaceholder, Stack, Text, Tooltip } from '@grafana/ui';
|
||||
import { RevisionModel, VersionHistoryComparison } from 'app/core/components/VersionHistory/VersionHistoryComparison';
|
||||
import { Alert, Button, EmptyState, LoadingPlaceholder, Stack, Text, Tooltip } from '@grafana/ui';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
import { GrafanaRuleDefinition, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { LogMessages, logInfo, trackRuleVersionsComparisonClick } from '../../../Analytics';
|
||||
import {
|
||||
LogMessages,
|
||||
logInfo,
|
||||
trackRuleVersionsComparisonClick,
|
||||
trackRuleVersionsRestoreFail,
|
||||
trackRuleVersionsRestoreSuccess,
|
||||
} from '../../../Analytics';
|
||||
import { alertRuleApi } from '../../../api/alertRuleApi';
|
||||
import { AlertRuleAction, useRulerRuleAbility } from '../../../hooks/useAbilities';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
|
||||
import { stringifyErrorLike } from '../../../utils/misc';
|
||||
|
||||
import { VersionHistoryTable } from './components/VersionHistoryTable';
|
||||
import { getSpecialUidsDisplayMap, preprocessRuleForDiffDisplay } from './versions-utils';
|
||||
import { ComparisonDrawer } from './version-history/ComparisonDrawer';
|
||||
import { Origin } from './version-history/ConfirmVersionRestoreModal';
|
||||
import { VersionHistoryTable } from './version-history/VersionHistoryTable';
|
||||
|
||||
const { useGetAlertVersionHistoryQuery } = alertRuleApi;
|
||||
|
||||
interface AlertVersionHistoryProps {
|
||||
ruleUid: string;
|
||||
rule: RulerGrafanaRuleDTO;
|
||||
}
|
||||
|
||||
/** List of (top level) properties to exclude from being shown in human readable summary of version changes */
|
||||
|
|
@ -32,9 +41,15 @@ export const grafanaAlertPropertiesToIgnore: Array<keyof GrafanaRuleDefinition>
|
|||
* Render the version history of a given Grafana managed alert rule, showing different edits
|
||||
* and allowing to restore to a previous version.
|
||||
*/
|
||||
export function AlertVersionHistory({ ruleUid }: AlertVersionHistoryProps) {
|
||||
export function AlertVersionHistory({ rule }: AlertVersionHistoryProps) {
|
||||
const ruleUid = rule.grafana_alert.uid;
|
||||
const { isLoading, currentData: ruleVersions = [], error } = useGetAlertVersionHistoryQuery({ uid: ruleUid });
|
||||
|
||||
const ruleIdentifier: RuleIdentifier = useMemo(
|
||||
() => ({ ruleSourceName: GRAFANA_RULES_SOURCE_NAME, uid: ruleUid }),
|
||||
[ruleUid]
|
||||
);
|
||||
|
||||
const [oldVersion, setOldVersion] = useState<RulerGrafanaRuleDTO<GrafanaRuleDefinition>>();
|
||||
const [newVersion, setNewVersion] = useState<RulerGrafanaRuleDTO<GrafanaRuleDefinition>>();
|
||||
const [showDrawer, setShowDrawer] = useState(false);
|
||||
|
|
@ -42,6 +57,42 @@ export function AlertVersionHistory({ ruleUid }: AlertVersionHistoryProps) {
|
|||
const [checkedVersions, setCheckedVersions] = useState(new Set<string>());
|
||||
const canCompare = useMemo(() => checkedVersions.size > 1, [checkedVersions]);
|
||||
|
||||
// check if restoring is allowed/enabled
|
||||
const groupIdentifier: RuleGroupIdentifierV2 = {
|
||||
namespace: { uid: rule.grafana_alert.namespace_uid },
|
||||
groupName: rule.grafana_alert.rule_group,
|
||||
groupOrigin: 'grafana',
|
||||
};
|
||||
const [restoreSupported, restoreAllowed] = useRulerRuleAbility(rule, groupIdentifier, AlertRuleAction.Restore);
|
||||
const canRestore =
|
||||
restoreAllowed && restoreSupported && Boolean(config.featureToggles.alertingRuleVersionHistoryRestore);
|
||||
|
||||
//tracking functions for restore action
|
||||
const onRestoreSuccess = useCallback(
|
||||
(origin: Origin) => {
|
||||
trackRuleVersionsRestoreSuccess({
|
||||
origin,
|
||||
latest: newVersion === ruleVersions[0],
|
||||
oldVersion: oldVersion?.grafana_alert.version || 0,
|
||||
newVersion: newVersion?.grafana_alert.version || 0,
|
||||
});
|
||||
},
|
||||
[oldVersion, newVersion, ruleVersions]
|
||||
);
|
||||
|
||||
const onRestoreFail = useCallback(
|
||||
(origin: Origin, error: Error) => {
|
||||
trackRuleVersionsRestoreFail({
|
||||
origin,
|
||||
latest: newVersion === ruleVersions[0],
|
||||
oldVersion: oldVersion?.grafana_alert.version || 0,
|
||||
newVersion: newVersion?.grafana_alert.version || 0,
|
||||
error,
|
||||
});
|
||||
},
|
||||
[oldVersion, newVersion, ruleVersions]
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert title={t('alerting.alertVersionHistory.errorloading', 'Failed to load alert rule versions')}>
|
||||
|
|
@ -65,7 +116,8 @@ export function AlertVersionHistory({ ruleUid }: AlertVersionHistoryProps) {
|
|||
);
|
||||
}
|
||||
|
||||
const compareVersions = () => {
|
||||
const compareSelectedVersions = () => {
|
||||
// precondition: we have only two versions selected in checkedVersions
|
||||
const [older, newer] = ruleVersions
|
||||
.filter((rule) => {
|
||||
const version = rule.grafana_alert.version;
|
||||
|
|
@ -89,8 +141,16 @@ export function AlertVersionHistory({ ruleUid }: AlertVersionHistoryProps) {
|
|||
newVersion: newer?.grafana_alert.version || 0,
|
||||
});
|
||||
|
||||
setOldVersion(older);
|
||||
setNewVersion(newer);
|
||||
// setting the versions to compare
|
||||
compareVersions(older, newer);
|
||||
};
|
||||
|
||||
const compareVersions = (
|
||||
oldRule: RulerGrafanaRuleDTO<GrafanaRuleDefinition>,
|
||||
newRule: RulerGrafanaRuleDTO<GrafanaRuleDefinition>
|
||||
) => {
|
||||
setOldVersion(oldRule);
|
||||
setNewVersion(newRule);
|
||||
setShowDrawer(true);
|
||||
};
|
||||
|
||||
|
|
@ -104,6 +164,8 @@ export function AlertVersionHistory({ ruleUid }: AlertVersionHistoryProps) {
|
|||
setNewVersion(undefined);
|
||||
}
|
||||
|
||||
const isNewLatest = ruleVersions[0].grafana_alert.version === newVersion?.grafana_alert.version;
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={2}>
|
||||
<Text variant="body">
|
||||
|
|
@ -117,74 +179,33 @@ export function AlertVersionHistory({ ruleUid }: AlertVersionHistoryProps) {
|
|||
content={t('core.versionHistory.comparison.select', 'Select two versions to start comparing')}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button type="button" disabled={!canCompare} onClick={compareVersions} icon="code-branch">
|
||||
<Button type="button" disabled={!canCompare} onClick={compareSelectedVersions} icon="code-branch">
|
||||
<Trans i18nKey="alerting.alertVersionHistory.compareVersions">Compare versions</Trans>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
{showDrawer && oldVersion && newVersion && (
|
||||
<Drawer
|
||||
onClose={() => setShowDrawer(false)}
|
||||
title={t('alerting.alertVersionHistory.comparing-versions', 'Comparing versions')}
|
||||
>
|
||||
<VersionHistoryComparison
|
||||
oldSummary={parseVersionInfoToSummary(oldVersion)}
|
||||
oldVersion={oldVersion}
|
||||
newSummary={parseVersionInfoToSummary(newVersion)}
|
||||
newVersion={newVersion}
|
||||
preprocessVersion={preprocessRuleForDiffDisplay}
|
||||
/>
|
||||
{config.featureToggles.alertingRuleVersionHistoryRestore && (
|
||||
<Box paddingTop={2}>
|
||||
<Stack justifyContent="flex-end">
|
||||
<Button variant="destructive" onClick={() => {}}>
|
||||
<Trans i18nKey="alerting.alertVersionHistory.reset">
|
||||
Reset to version {{ version: oldVersion.grafana_alert.version }}
|
||||
</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Drawer>
|
||||
<ComparisonDrawer
|
||||
oldVersion={oldVersion}
|
||||
newVersion={newVersion}
|
||||
ruleIdentifier={ruleIdentifier}
|
||||
isNewLatest={isNewLatest}
|
||||
setShowDrawer={setShowDrawer}
|
||||
onRestoreSuccess={() => onRestoreSuccess('comparison-drawer')}
|
||||
onRestoreError={(err: Error) => onRestoreFail('comparison-drawer', err)}
|
||||
canRestore={canRestore}
|
||||
/>
|
||||
)}
|
||||
|
||||
<VersionHistoryTable
|
||||
onCompareSingleVersion={(rule) => compareVersions(rule, ruleVersions[0])}
|
||||
onVersionsChecked={handleCheckedVersionChange}
|
||||
ruleVersions={ruleVersions}
|
||||
disableSelection={canCompare}
|
||||
checkedVersions={checkedVersions}
|
||||
onRestoreSuccess={() => onRestoreSuccess('version-list')}
|
||||
onRestoreError={(err: Error) => onRestoreFail('version-list', err)}
|
||||
canRestore={canRestore}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a version of a Grafana rule definition into data structure
|
||||
* used to display the version summary when comparing versions
|
||||
*/
|
||||
function parseVersionInfoToSummary(version: RulerGrafanaRuleDTO<GrafanaRuleDefinition>): RevisionModel {
|
||||
const unknown = t('alerting.alertVersionHistory.unknown', 'Unknown');
|
||||
const SPECIAL_UID_MAP = getSpecialUidsDisplayMap();
|
||||
const createdBy = (() => {
|
||||
const updatedBy = version?.grafana_alert.updated_by;
|
||||
const uid = updatedBy?.uid;
|
||||
const name = updatedBy?.name;
|
||||
|
||||
if (!updatedBy) {
|
||||
return unknown;
|
||||
}
|
||||
if (uid && SPECIAL_UID_MAP[uid]) {
|
||||
return SPECIAL_UID_MAP[uid].name;
|
||||
}
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
return uid ? t('alerting.alertVersionHistory.user-id', 'User ID {{uid}}', { uid }) : unknown;
|
||||
})();
|
||||
|
||||
return {
|
||||
created: version.grafana_alert.updated || unknown,
|
||||
createdBy,
|
||||
version: version.grafana_alert.version || unknown,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { isNullDate } from '../../../utils/time';
|
|||
import { Tokenize } from '../../Tokenize';
|
||||
import { DetailText } from '../../common/DetailText';
|
||||
|
||||
import { UpdatedByUser } from './components/UpdatedBy';
|
||||
import { UpdatedByUser } from './version-history/UpdatedBy';
|
||||
|
||||
enum RuleType {
|
||||
GrafanaManagedAlertRule = 'Grafana-managed alert rule',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { Drawer } from '@grafana/ui';
|
||||
import { VersionHistoryComparison } from 'app/core/components/VersionHistory/VersionHistoryComparison';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { GrafanaRuleIdentifier } from 'app/types/unified-alerting';
|
||||
import { GrafanaRuleDefinition, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { ConfirmVersionRestoreModal } from './ConfirmVersionRestoreModal';
|
||||
import { parseVersionInfoToSummary, preprocessRuleForDiffDisplay } from './versions-utils';
|
||||
|
||||
interface ComparisonDrawerProps {
|
||||
oldVersion: RulerGrafanaRuleDTO<GrafanaRuleDefinition>;
|
||||
newVersion: RulerGrafanaRuleDTO<GrafanaRuleDefinition>;
|
||||
ruleIdentifier: GrafanaRuleIdentifier;
|
||||
isNewLatest: boolean;
|
||||
setShowDrawer: (show: boolean) => void;
|
||||
onRestoreSuccess: () => void;
|
||||
onRestoreError: (error: Error) => void;
|
||||
canRestore: boolean;
|
||||
}
|
||||
|
||||
export const ComparisonDrawer = ({
|
||||
oldVersion,
|
||||
newVersion,
|
||||
ruleIdentifier,
|
||||
isNewLatest,
|
||||
setShowDrawer,
|
||||
onRestoreSuccess,
|
||||
onRestoreError,
|
||||
canRestore,
|
||||
}: ComparisonDrawerProps) => {
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const onDismiss = useCallback(() => setShowDrawer(false), [setShowDrawer]);
|
||||
|
||||
const oldVersionSummary = parseVersionInfoToSummary(oldVersion);
|
||||
const newVersionSummary = parseVersionInfoToSummary(newVersion);
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
onClose={() => setShowDrawer(false)}
|
||||
title={t('alerting.alertVersionHistory.comparing-versions', 'Comparing versions')}
|
||||
>
|
||||
<VersionHistoryComparison
|
||||
oldSummary={oldVersionSummary}
|
||||
oldVersion={oldVersion}
|
||||
newSummary={newVersionSummary}
|
||||
newVersion={newVersion}
|
||||
preprocessVersion={preprocessRuleForDiffDisplay}
|
||||
showRestoreButton={isNewLatest && canRestore}
|
||||
onRestore={() => setShowConfirmModal(true)}
|
||||
/>
|
||||
</Drawer>
|
||||
{showConfirmModal && (
|
||||
<ConfirmVersionRestoreModal
|
||||
ruleIdentifier={ruleIdentifier}
|
||||
baseVersion={newVersion}
|
||||
versionToRestore={oldVersion}
|
||||
isOpen={showConfirmModal}
|
||||
onDismiss={onDismiss}
|
||||
onRestoreSucess={onRestoreSuccess}
|
||||
onRestoreError={onRestoreError}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { ComponentProps } from 'react';
|
||||
import { useNavigate } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { urlUtil } from '@grafana/data';
|
||||
import { Alert, ConfirmModal, Stack, Text } from '@grafana/ui';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { useRuleWithLocation } from 'app/features/alerting/unified/hooks/useCombinedRule';
|
||||
import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
|
||||
import { rulerRuleToFormValues } from 'app/features/alerting/unified/utils/rule-form';
|
||||
import { DiffGroup } from 'app/features/dashboard-scene/settings/version-history/DiffGroup';
|
||||
import { jsonDiff } from 'app/features/dashboard-scene/settings/version-history/utils';
|
||||
import { GrafanaRuleIdentifier } from 'app/types/unified-alerting';
|
||||
import { GrafanaRuleDefinition, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { useRestoreVersion } from './useRestoreVersion';
|
||||
import { preprocessRuleForDiffDisplay } from './versions-utils';
|
||||
|
||||
export type Origin = 'version-list' | 'comparison-drawer';
|
||||
|
||||
type ModalProps = Pick<ComponentProps<typeof ConfirmModal>, 'isOpen' | 'onDismiss'> & {
|
||||
isOpen: boolean;
|
||||
baseVersion?: RulerGrafanaRuleDTO<GrafanaRuleDefinition>;
|
||||
versionToRestore?: RulerGrafanaRuleDTO<GrafanaRuleDefinition>;
|
||||
ruleIdentifier: GrafanaRuleIdentifier;
|
||||
onRestoreSucess: () => void;
|
||||
onRestoreError: (error: Error) => void;
|
||||
};
|
||||
|
||||
export const ConfirmVersionRestoreModal = ({
|
||||
isOpen,
|
||||
baseVersion,
|
||||
versionToRestore,
|
||||
ruleIdentifier,
|
||||
onDismiss,
|
||||
onRestoreSucess,
|
||||
onRestoreError,
|
||||
}: ModalProps) => {
|
||||
const { result: ruleWithLocation } = useRuleWithLocation({ ruleIdentifier });
|
||||
const navigate = useNavigate();
|
||||
const [restoreMethod, { error }] = useRestoreVersion();
|
||||
|
||||
const title = t('alerting.alertVersionHistory.restore-modal.title', 'Restore version');
|
||||
const errorTitle = t('alerting.alertVersionHistory.restore-modal.error', 'Could not restore alert rule version ');
|
||||
const confirmText = !error
|
||||
? t('alerting.alertVersionHistory.restore-modal.confirm', 'Yes, restore configuration')
|
||||
: 'Manually restore rule';
|
||||
|
||||
const diff =
|
||||
baseVersion && versionToRestore
|
||||
? jsonDiff(preprocessRuleForDiffDisplay(baseVersion), preprocessRuleForDiffDisplay(versionToRestore))
|
||||
: undefined;
|
||||
|
||||
async function onRestoreConfirm() {
|
||||
if (!versionToRestore || !ruleWithLocation) {
|
||||
return;
|
||||
}
|
||||
return restoreMethod
|
||||
.execute(versionToRestore, ruleWithLocation)
|
||||
.then(() => {
|
||||
onDismiss();
|
||||
onRestoreSucess();
|
||||
})
|
||||
.catch((err) => {
|
||||
onRestoreError(err);
|
||||
});
|
||||
}
|
||||
|
||||
async function onManualRestore() {
|
||||
if (!versionToRestore || !ruleWithLocation) {
|
||||
return;
|
||||
}
|
||||
const payload = rulerRuleToFormValues({ ...ruleWithLocation, rule: versionToRestore });
|
||||
const ruleFormUrl = urlUtil.renderUrl(`/alerting/${ruleIdentifier.uid}/edit`, {
|
||||
isManualRestore: true,
|
||||
defaults: JSON.stringify(payload),
|
||||
returnTo: location.pathname + location.search,
|
||||
});
|
||||
|
||||
navigate(ruleFormUrl);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
isOpen={isOpen}
|
||||
title={title}
|
||||
confirmText={confirmText}
|
||||
confirmButtonVariant={!error ? 'destructive' : 'primary'}
|
||||
body={
|
||||
<Stack direction="column" gap={2}>
|
||||
{!error && (
|
||||
<Trans i18nKey="alerting.alertVersionHistory.restore-modal.body">
|
||||
Are you sure you want to restore the alert rule definition to this version? All unsaved changes will be
|
||||
lost.
|
||||
</Trans>
|
||||
)}
|
||||
<Text variant="h6">
|
||||
<Trans i18nKey="alerting.alertVersionHistory.restore-modal.summary">
|
||||
Summary of changes to be applied:
|
||||
</Trans>
|
||||
</Text>
|
||||
<div>
|
||||
{diff && Object.entries(diff).map(([key, diffs]) => <DiffGroup diffs={diffs} key={key} title={key} />)}
|
||||
</div>
|
||||
{error && (
|
||||
<Alert severity="warning" title={errorTitle}>
|
||||
<Trans i18nKey="alerting.alertVersionHistory.restore-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 rule version
|
||||
</Trans>
|
||||
<pre style={{ marginBottom: 0 }}>
|
||||
<code>{stringifyErrorLike(error)}</code>
|
||||
</pre>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
onConfirm={!error ? onRestoreConfirm : onManualRestore}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -4,7 +4,7 @@ import { Badge, Icon, Tooltip, useStyles2 } from '@grafana/ui';
|
|||
import { t } from 'app/core/internationalization';
|
||||
import { UpdatedBy } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { getSpecialUidsDisplayMap } from '../versions-utils';
|
||||
import { getSpecialUidsDisplayMap } from './versions-utils';
|
||||
|
||||
export const UpdatedByUser = ({ user }: { user: UpdatedBy | null | undefined }) => {
|
||||
const unknown = t('alerting.alertVersionHistory.unknown', 'Unknown');
|
||||
|
|
@ -1,51 +1,58 @@
|
|||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Badge, Button, Checkbox, Column, ConfirmModal, InteractiveTable, Stack, Text } from '@grafana/ui';
|
||||
import { Badge, Button, Checkbox, Column, InteractiveTable, Stack, Text } from '@grafana/ui';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
|
||||
import { computeVersionDiff } from 'app/features/alerting/unified/utils/diff';
|
||||
import { DiffGroup } from 'app/features/dashboard-scene/settings/version-history/DiffGroup';
|
||||
import { Diffs, jsonDiff } from 'app/features/dashboard-scene/settings/version-history/utils';
|
||||
import { RuleIdentifier } from 'app/types/unified-alerting';
|
||||
import { GrafanaRuleDefinition, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { ConfirmVersionRestoreModal } from './ConfirmVersionRestoreModal';
|
||||
import { UpdatedByUser } from './UpdatedBy';
|
||||
|
||||
const VERSIONS_PAGE_SIZE = 20;
|
||||
|
||||
export function VersionHistoryTable({
|
||||
onVersionsChecked,
|
||||
ruleVersions,
|
||||
disableSelection,
|
||||
checkedVersions,
|
||||
}: {
|
||||
export interface VersionHistoryTableProps {
|
||||
onVersionsChecked(id: string): void;
|
||||
onCompareSingleVersion(rule: RulerGrafanaRuleDTO<GrafanaRuleDefinition>): void;
|
||||
ruleVersions: Array<RulerGrafanaRuleDTO<GrafanaRuleDefinition>>;
|
||||
disableSelection: boolean;
|
||||
checkedVersions: Set<string>;
|
||||
}) {
|
||||
//----> restore code : no need to review as it's behind a feature flag
|
||||
const [confirmRestore, setConfirmRestore] = useState(false);
|
||||
const [restoreDiff, setRestoreDiff] = useState<Diffs | undefined>();
|
||||
onRestoreSuccess: () => void;
|
||||
onRestoreError: (error: Error) => void;
|
||||
canRestore: boolean;
|
||||
}
|
||||
export function VersionHistoryTable({
|
||||
onVersionsChecked,
|
||||
onCompareSingleVersion,
|
||||
ruleVersions,
|
||||
disableSelection,
|
||||
checkedVersions,
|
||||
onRestoreSuccess,
|
||||
onRestoreError,
|
||||
canRestore,
|
||||
}: VersionHistoryTableProps) {
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [ruleToRestore, setRuleToRestore] = useState<RulerGrafanaRuleDTO<GrafanaRuleDefinition>>();
|
||||
const ruleToRestoreUid = ruleToRestore?.grafana_alert?.uid ?? '';
|
||||
const ruleIdentifier: RuleIdentifier = useMemo(
|
||||
() => ({ ruleSourceName: GRAFANA_RULES_SOURCE_NAME, uid: ruleToRestoreUid }),
|
||||
[ruleToRestoreUid]
|
||||
);
|
||||
|
||||
const showConfirmation = (id: string) => {
|
||||
const currentVersion = ruleVersions[0];
|
||||
const restoreVersion = ruleVersions.find((rule) => String(rule.grafana_alert.version) === id);
|
||||
if (!restoreVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConfirmRestore(true);
|
||||
setRestoreDiff(jsonDiff(currentVersion, restoreVersion));
|
||||
const showConfirmation = (ruleToRestore: RulerGrafanaRuleDTO<GrafanaRuleDefinition>) => {
|
||||
setShowConfirmModal(true);
|
||||
setRuleToRestore(ruleToRestore);
|
||||
};
|
||||
|
||||
const hideConfirmation = () => {
|
||||
setConfirmRestore(false);
|
||||
setShowConfirmModal(false);
|
||||
};
|
||||
//----> end of restore code
|
||||
|
||||
const unknown = t('alerting.alertVersionHistory.unknown', 'Unknown');
|
||||
|
||||
const columns: Array<Column<(typeof ruleVersions)[0]>> = [
|
||||
const columns: Array<Column<RulerGrafanaRuleDTO<GrafanaRuleDefinition>>> = [
|
||||
{
|
||||
disableGrow: true,
|
||||
id: 'id',
|
||||
|
|
@ -116,22 +123,36 @@ export function VersionHistoryTable({
|
|||
disableGrow: true,
|
||||
cell: ({ row }) => {
|
||||
const isFirstItem = row.index === 0;
|
||||
const compareWithLatest = t('alerting.alertVersionHistory.compare-with-latest', 'Compare with latest version');
|
||||
|
||||
return (
|
||||
<Stack direction="row" alignItems="center" justifyContent="flex-end">
|
||||
{isFirstItem ? (
|
||||
<Badge text={t('alerting.alertVersionHistory.latest', 'Latest')} color="blue" />
|
||||
) : config.featureToggles.alertingRuleVersionHistoryRestore ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="history"
|
||||
onClick={() => {
|
||||
showConfirmation(row.values.id);
|
||||
}}
|
||||
>
|
||||
<Trans i18nKey="alerting.alertVersionHistory.restore">Restore</Trans>
|
||||
</Button>
|
||||
) : canRestore ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="code-branch"
|
||||
onClick={() => {
|
||||
onCompareSingleVersion(row.original);
|
||||
}}
|
||||
tooltip={compareWithLatest}
|
||||
>
|
||||
<Trans i18nKey="alerting.alertVersionHistory.compare">Compare</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="history"
|
||||
onClick={() => {
|
||||
row.original.grafana_alert.version && showConfirmation(row.original);
|
||||
}}
|
||||
>
|
||||
<Trans i18nKey="alerting.alertVersionHistory.restore">Restore</Trans>
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
|
|
@ -147,39 +168,15 @@ export function VersionHistoryTable({
|
|||
data={ruleVersions}
|
||||
getRowId={(row) => `${row.grafana_alert.version}`}
|
||||
/>
|
||||
{/* ---------------------> restore code: no need to review for this pr as it's behind a feature flag */}
|
||||
<ConfirmModal
|
||||
isOpen={confirmRestore}
|
||||
title={t('alerting.alertVersionHistory.restore-modal.title', 'Restore Version')}
|
||||
body={
|
||||
<Stack direction="column" gap={2}>
|
||||
<Trans i18nKey="alerting.alertVersionHistory.restore-modal.body">
|
||||
Are you sure you want to restore the alert rule definition to this version? All unsaved changes will be
|
||||
lost.
|
||||
</Trans>
|
||||
<Text variant="h6">
|
||||
<Trans i18nKey="alerting.alertVersionHistory.restore-modal.summary">
|
||||
Summary of changes to be applied:
|
||||
</Trans>
|
||||
</Text>
|
||||
<div>
|
||||
{restoreDiff && (
|
||||
<>
|
||||
{Object.entries(restoreDiff).map(([key, diffs]) => (
|
||||
<DiffGroup diffs={diffs} key={key} title={key} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
}
|
||||
confirmText={'Yes, restore configuration'}
|
||||
onConfirm={() => {
|
||||
hideConfirmation();
|
||||
}}
|
||||
onDismiss={() => hideConfirmation()}
|
||||
<ConfirmVersionRestoreModal
|
||||
ruleIdentifier={ruleIdentifier}
|
||||
baseVersion={ruleVersions[0]}
|
||||
versionToRestore={ruleToRestore}
|
||||
isOpen={showConfirmModal}
|
||||
onDismiss={hideConfirmation}
|
||||
onRestoreSucess={onRestoreSuccess}
|
||||
onRestoreError={onRestoreError}
|
||||
/>
|
||||
{/* ------------------------------------> END OF RESTORING CODE */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { useUpdateRuleInRuleGroup } from 'app/features/alerting/unified/hooks/ruleGroup/useUpsertRuleFromRuleGroup';
|
||||
import { useAsync } from 'app/features/alerting/unified/hooks/useAsync';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
|
||||
import { fromRulerRuleAndRuleGroupIdentifier } from 'app/features/alerting/unified/utils/rule-id';
|
||||
import { getRuleGroupLocationFromRuleWithLocation } from 'app/features/alerting/unified/utils/rules';
|
||||
import { RuleWithLocation } from 'app/types/unified-alerting';
|
||||
import { GrafanaRuleDefinition, RulerGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
export function useRestoreVersion() {
|
||||
const [updateRuleInRuleGroup] = useUpdateRuleInRuleGroup();
|
||||
|
||||
return useAsync(
|
||||
async (
|
||||
newVersion: RulerGrafanaRuleDTO<GrafanaRuleDefinition>,
|
||||
ruleWithLocation: RuleWithLocation<RulerRuleDTO>
|
||||
) => {
|
||||
const ruleGroupIdentifier = getRuleGroupLocationFromRuleWithLocation(ruleWithLocation);
|
||||
const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, newVersion);
|
||||
// restore version
|
||||
return updateRuleInRuleGroup.execute(ruleGroupIdentifier, ruleIdentifier, newVersion, {
|
||||
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
|
||||
namespaceName: newVersion.grafana_alert.namespace_uid,
|
||||
groupName: newVersion.grafana_alert.rule_group,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { IconName } from '@grafana/data';
|
||||
import { BadgeColor } from '@grafana/ui';
|
||||
import { RevisionModel } from 'app/core/components/VersionHistory/VersionHistoryComparison';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import {
|
||||
GrafanaAlertRuleDTOField,
|
||||
|
|
@ -8,7 +9,7 @@ import {
|
|||
TopLevelGrafanaRuleDTOField,
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { grafanaAlertPropertiesToIgnore } from './AlertVersionHistory';
|
||||
import { grafanaAlertPropertiesToIgnore } from '../AlertVersionHistory';
|
||||
|
||||
interface SpecialUidsDisplayMapEntry {
|
||||
name: string;
|
||||
|
|
@ -104,3 +105,34 @@ export function preprocessRuleForDiffDisplay(rulerRule: RulerGrafanaRuleDTO<Graf
|
|||
...processedGrafanaAlert,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a version of a Grafana rule definition into data structure
|
||||
* used to display the version summary when comparing versions
|
||||
*/
|
||||
export function parseVersionInfoToSummary(version: RulerGrafanaRuleDTO<GrafanaRuleDefinition>): RevisionModel {
|
||||
const unknown = t('alerting.alertVersionHistory.unknown', 'Unknown');
|
||||
const SPECIAL_UID_MAP = getSpecialUidsDisplayMap();
|
||||
const createdBy = (() => {
|
||||
const updatedBy = version?.grafana_alert.updated_by;
|
||||
const uid = updatedBy?.uid;
|
||||
const name = updatedBy?.name;
|
||||
|
||||
if (!updatedBy) {
|
||||
return unknown;
|
||||
}
|
||||
if (uid && SPECIAL_UID_MAP[uid]) {
|
||||
return SPECIAL_UID_MAP[uid].name;
|
||||
}
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
return uid ? t('alerting.alertVersionHistory.user-id', 'User ID {{uid}}', { uid }) : unknown;
|
||||
})();
|
||||
|
||||
return {
|
||||
createdAt: version.grafana_alert.updated || unknown,
|
||||
createdBy,
|
||||
version: version.grafana_alert.version || unknown,
|
||||
};
|
||||
}
|
||||
|
|
@ -22,6 +22,10 @@ exports[`AlertRule abilities should report no permissions while we are loading d
|
|||
false,
|
||||
false,
|
||||
],
|
||||
"restore-alert-rule": [
|
||||
false,
|
||||
false,
|
||||
],
|
||||
"silence-alert-rule": [
|
||||
false,
|
||||
false,
|
||||
|
|
@ -59,6 +63,10 @@ exports[`AlertRule abilities should report that all actions are supported for a
|
|||
true,
|
||||
false,
|
||||
],
|
||||
"restore-alert-rule": [
|
||||
true,
|
||||
false,
|
||||
],
|
||||
"silence-alert-rule": [
|
||||
true,
|
||||
false,
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export enum AlertRuleAction {
|
|||
Silence = 'silence-alert-rule',
|
||||
ModifyExport = 'modify-export-rule',
|
||||
Pause = 'pause-alert-rule',
|
||||
Restore = 'restore-alert-rule',
|
||||
}
|
||||
|
||||
// this enum lists all of the actions we can perform within alerting in general, not linked to a specific
|
||||
|
|
@ -231,6 +232,7 @@ export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities<AlertRul
|
|||
[AlertRuleAction.Silence]: canSilence,
|
||||
[AlertRuleAction.ModifyExport]: [isGrafanaManagedAlertRule, exportAllowed],
|
||||
[AlertRuleAction.Pause]: [MaybeSupportedUnlessImmutable && isGrafanaManagedAlertRule, isEditable ?? false],
|
||||
[AlertRuleAction.Restore]: [MaybeSupportedUnlessImmutable && isGrafanaManagedAlertRule, isEditable ?? false],
|
||||
};
|
||||
|
||||
return abilities;
|
||||
|
|
@ -277,6 +279,7 @@ export function useAllRulerRuleAbilities(
|
|||
[AlertRuleAction.Silence]: canSilence,
|
||||
[AlertRuleAction.ModifyExport]: [isGrafanaManagedAlertRule, exportAllowed],
|
||||
[AlertRuleAction.Pause]: [MaybeSupportedUnlessImmutable && isGrafanaManagedAlertRule, isEditable ?? false],
|
||||
[AlertRuleAction.Restore]: [MaybeSupportedUnlessImmutable && isGrafanaManagedAlertRule, isEditable ?? false],
|
||||
};
|
||||
|
||||
return abilities;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
|
|
@ -172,9 +173,18 @@ export interface RuleLocation {
|
|||
}
|
||||
|
||||
export function useRuleLocation(ruleIdentifier: RuleIdentifier): RequestState<RuleLocation> {
|
||||
const validIdentifier = (() => {
|
||||
if (isGrafanaRuleIdentifier(ruleIdentifier) && ruleIdentifier.uid !== '') {
|
||||
return { uid: ruleIdentifier.uid };
|
||||
}
|
||||
return skipToken;
|
||||
})();
|
||||
|
||||
const { isLoading, currentData, error, isUninitialized } = alertRuleApi.endpoints.getAlertRule.useQuery(
|
||||
{ uid: isGrafanaRuleIdentifier(ruleIdentifier) ? ruleIdentifier.uid : '' },
|
||||
{ skip: !isGrafanaRuleIdentifier(ruleIdentifier), refetchOnMountOrArgChange: true }
|
||||
validIdentifier,
|
||||
{
|
||||
refetchOnMountOrArgChange: true,
|
||||
}
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -1,23 +1,29 @@
|
|||
import { Alert, LoadingPlaceholder } from '@grafana/ui';
|
||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||
import { RuleIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import { AlertWarning } from '../AlertWarning';
|
||||
import { AlertRuleForm } from '../components/rule-editor/alert-rule-form/AlertRuleForm';
|
||||
import { useRuleWithLocation } from '../hooks/useCombinedRule';
|
||||
import { useIsRuleEditable } from '../hooks/useIsRuleEditable';
|
||||
import { RuleFormValues } from '../types/rule-form';
|
||||
import { stringifyErrorLike } from '../utils/misc';
|
||||
import * as ruleId from '../utils/rule-id';
|
||||
|
||||
interface ExistingRuleEditorProps {
|
||||
identifier: RuleIdentifier;
|
||||
/** Provide prefill if we are trying to restore an old version of an alert rule but we need the user to manually tweak the values */
|
||||
prefill?: Partial<RuleFormValues>;
|
||||
}
|
||||
|
||||
export function ExistingRuleEditor({ identifier }: ExistingRuleEditorProps) {
|
||||
export function ExistingRuleEditor({ identifier, prefill }: ExistingRuleEditorProps) {
|
||||
const {
|
||||
loading: loadingAlertRule,
|
||||
result: ruleWithLocation,
|
||||
error,
|
||||
} = useRuleWithLocation({ ruleIdentifier: identifier });
|
||||
const [queryParams] = useQueryParams();
|
||||
const isManualRestore = Boolean(queryParams.isManualRestore);
|
||||
|
||||
const ruleSourceName = ruleId.ruleIdentifierToRuleSourceName(identifier);
|
||||
|
||||
|
|
@ -45,5 +51,5 @@ export function ExistingRuleEditor({ identifier }: ExistingRuleEditorProps) {
|
|||
return <AlertWarning title="Cannot edit rule">Sorry! You do not have permission to edit this rule.</AlertWarning>;
|
||||
}
|
||||
|
||||
return <AlertRuleForm existing={ruleWithLocation} />;
|
||||
return <AlertRuleForm existing={ruleWithLocation} prefill={prefill} isManualRestore={isManualRestore} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ const RuleEditor = () => {
|
|||
}
|
||||
|
||||
if (identifier) {
|
||||
return <ExistingRuleEditor key={JSON.stringify(identifier)} identifier={identifier} />;
|
||||
return <ExistingRuleEditor key={JSON.stringify(identifier)} identifier={identifier} prefill={queryDefaults} />;
|
||||
}
|
||||
|
||||
if (copyFromIdentifier) {
|
||||
|
|
|
|||
|
|
@ -209,6 +209,8 @@
|
|||
"alerting": "Alerting",
|
||||
"alerting-change-description": "This update was made by the alerting system due to other changes. For example, when renaming a contact point that is used for simplified routing, this will update affected rules",
|
||||
"annotations": "Annotations",
|
||||
"compare": "Compare",
|
||||
"compare-with-latest": "Compare with latest version",
|
||||
"compareVersions": "Compare versions",
|
||||
"comparing-versions": "Comparing versions",
|
||||
"condition": "Alert condition",
|
||||
|
|
@ -228,17 +230,22 @@
|
|||
"provisioning": "Provisioning",
|
||||
"provisioning-change-description": "Version update was made via provisioning",
|
||||
"queryAndAlertCondition": "Query and alert condition",
|
||||
"reset": "Reset to version {{version}}",
|
||||
"restore": "Restore",
|
||||
"restore-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 rule version",
|
||||
"restore-modal": {
|
||||
"body": "Are you sure you want to restore the alert rule definition to this version? All unsaved changes will be lost.",
|
||||
"confirm": "Yes, restore configuration",
|
||||
"error": "Could not restore alert rule version ",
|
||||
"summary": "Summary of changes to be applied:",
|
||||
"title": "Restore Version"
|
||||
"title": "Restore version"
|
||||
},
|
||||
"restore-version": "Restore to version {{version}}",
|
||||
"rule_group": "Rule group",
|
||||
"unknown": "Unknown",
|
||||
"unknown-change-description": "This update was made prior to the implementation of alert rule version history. The user who made the change is not tracked, but future changes will include the user",
|
||||
"user-id": "User ID {{uid}}"
|
||||
"user-id": "User ID {{uid}}",
|
||||
"warning-restore-manually": "You are manually restoring an old version of this alert rule. Please review the changes carefully before saving the rule definition.",
|
||||
"warning-restore-manually-title": "Restoring rule manually"
|
||||
},
|
||||
"annotations": {
|
||||
"description": "Add more context to your alert notifications.",
|
||||
|
|
|
|||
|
|
@ -209,6 +209,8 @@
|
|||
"alerting": "Åľęřŧįʼnģ",
|
||||
"alerting-change-description": "Ŧĥįş ūpđäŧę ŵäş mäđę þy ŧĥę äľęřŧįʼnģ şyşŧęm đūę ŧő őŧĥęř čĥäʼnģęş. Főř ęχämpľę, ŵĥęʼn řęʼnämįʼnģ ä čőʼnŧäčŧ pőįʼnŧ ŧĥäŧ įş ūşęđ ƒőř şįmpľįƒįęđ řőūŧįʼnģ, ŧĥįş ŵįľľ ūpđäŧę 䃃ęčŧęđ řūľęş",
|
||||
"annotations": "Åʼnʼnőŧäŧįőʼnş",
|
||||
"compare": "Cőmpäřę",
|
||||
"compare-with-latest": "Cőmpäřę ŵįŧĥ ľäŧęşŧ vęřşįőʼn",
|
||||
"compareVersions": "Cőmpäřę vęřşįőʼnş",
|
||||
"comparing-versions": "Cőmpäřįʼnģ vęřşįőʼnş",
|
||||
"condition": "Åľęřŧ čőʼnđįŧįőʼn",
|
||||
|
|
@ -228,17 +230,22 @@
|
|||
"provisioning": "Přővįşįőʼnįʼnģ",
|
||||
"provisioning-change-description": "Vęřşįőʼn ūpđäŧę ŵäş mäđę vįä přővįşįőʼnįʼnģ",
|
||||
"queryAndAlertCondition": "Qūęřy äʼnđ äľęřŧ čőʼnđįŧįőʼn",
|
||||
"reset": "Ŗęşęŧ ŧő vęřşįőʼn {{version}}",
|
||||
"restore": "Ŗęşŧőřę",
|
||||
"restore-manually": "Ÿőūř äľęřŧ řūľę čőūľđ ʼnőŧ þę řęşŧőřęđ. Ŧĥįş mäy þę đūę ŧő čĥäʼnģęş ŧő őŧĥęř ęʼnŧįŧįęş şūčĥ äş čőʼnŧäčŧ pőįʼnŧş, đäŧä şőūřčęş ęŧč. Pľęäşę mäʼnūäľľy řęşŧőřę ŧĥę řūľę vęřşįőʼn",
|
||||
"restore-modal": {
|
||||
"body": "Åřę yőū şūřę yőū ŵäʼnŧ ŧő řęşŧőřę ŧĥę äľęřŧ řūľę đęƒįʼnįŧįőʼn ŧő ŧĥįş vęřşįőʼn? Åľľ ūʼnşävęđ čĥäʼnģęş ŵįľľ þę ľőşŧ.",
|
||||
"confirm": "Ÿęş, řęşŧőřę čőʼnƒįģūřäŧįőʼn",
|
||||
"error": "Cőūľđ ʼnőŧ řęşŧőřę äľęřŧ řūľę vęřşįőʼn ",
|
||||
"summary": "Ŝūmmäřy őƒ čĥäʼnģęş ŧő þę äppľįęđ:",
|
||||
"title": "Ŗęşŧőřę Vęřşįőʼn"
|
||||
"title": "Ŗęşŧőřę vęřşįőʼn"
|
||||
},
|
||||
"restore-version": "Ŗęşŧőřę ŧő vęřşįőʼn {{version}}",
|
||||
"rule_group": "Ŗūľę ģřőūp",
|
||||
"unknown": "Ůʼnĸʼnőŵʼn",
|
||||
"unknown-change-description": "Ŧĥįş ūpđäŧę ŵäş mäđę přįőř ŧő ŧĥę įmpľęmęʼnŧäŧįőʼn őƒ äľęřŧ řūľę vęřşįőʼn ĥįşŧőřy. Ŧĥę ūşęř ŵĥő mäđę ŧĥę čĥäʼnģę įş ʼnőŧ ŧřäčĸęđ, þūŧ ƒūŧūřę čĥäʼnģęş ŵįľľ įʼnčľūđę ŧĥę ūşęř",
|
||||
"user-id": "Ůşęř ĨĐ {{uid}}"
|
||||
"user-id": "Ůşęř ĨĐ {{uid}}",
|
||||
"warning-restore-manually": "Ÿőū äřę mäʼnūäľľy řęşŧőřįʼnģ äʼn őľđ vęřşįőʼn őƒ ŧĥįş äľęřŧ řūľę. Pľęäşę řęvįęŵ ŧĥę čĥäʼnģęş čäřęƒūľľy þęƒőřę şävįʼnģ ŧĥę řūľę đęƒįʼnįŧįőʼn.",
|
||||
"warning-restore-manually-title": "Ŗęşŧőřįʼnģ řūľę mäʼnūäľľy"
|
||||
},
|
||||
"annotations": {
|
||||
"description": "Åđđ mőřę čőʼnŧęχŧ ŧő yőūř äľęřŧ ʼnőŧįƒįčäŧįőʼnş.",
|
||||
|
|
|
|||
Loading…
Reference in New Issue