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:
Sonia Aguilar 2025-02-28 12:14:23 +01:00 committed by GitHub
parent ae2074ef55
commit e73b78a134
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 558 additions and 175 deletions

View File

@ -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",

View File

@ -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

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
232 grafanaconThemes experimental @grafana/grafana-frontend-platform false true false
233 pluginsCDNSyncLoader experimental @grafana/plugins-platform-backend false false false
234 alertingJiraIntegration experimental @grafana/alerting-squad false false true
235 alertingRuleVersionHistoryRestore experimental GA @grafana/alerting-squad false false true
236 newShareReportDrawer experimental @grafana/sharing-squad false false false
237 rendererDisableAppPluginsPreload experimental @grafana/sharing-squad false false true

View File

@ -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"
}
},
{

View File

@ -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)} />
)}

View File

@ -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';

View File

@ -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

View File

@ -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 */}

View File

@ -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>

View File

@ -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>

View File

@ -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,
};
}

View File

@ -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',

View File

@ -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}
/>
)}
</>
);
};

View File

@ -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}
/>
);
};

View File

@ -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');

View File

@ -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 */}
</>
);
}

View File

@ -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,
});
}
);
}

View File

@ -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,
};
}

View File

@ -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,

View File

@ -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;

View File

@ -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(() => {

View File

@ -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} />;
}

View File

@ -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) {

View File

@ -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.",

View File

@ -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ş.",