From e73b78a13439ba81047e938f07fc3cba28d34236 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Fri, 28 Feb 2025 12:14:23 +0100 Subject: [PATCH] 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 --- pkg/services/featuremgmt/registry.go | 3 +- pkg/services/featuremgmt/toggles_gen.csv | 2 +- pkg/services/featuremgmt/toggles_gen.json | 9 +- .../VersionHistoryComparison.tsx | 40 +++-- .../features/alerting/unified/Analytics.ts | 11 ++ .../ContactPointSelector.tsx | 12 +- .../alert-rule-form/AlertRuleForm.tsx | 33 +++- .../contactPoint/ContactPointSelector.tsx | 13 +- .../components/rule-viewer/RuleViewer.tsx | 2 +- .../rule-viewer/tabs/AlertVersionHistory.tsx | 155 ++++++++++-------- .../components/rule-viewer/tabs/Details.tsx | 2 +- .../tabs/version-history/ComparisonDrawer.tsx | 67 ++++++++ .../ConfirmVersionRestoreModal.tsx | 121 ++++++++++++++ .../UpdatedBy.tsx | 2 +- .../VersionHistoryTable.tsx | 137 ++++++++-------- .../tabs/version-history/useRestoreVersion.ts | 27 +++ .../{ => version-history}/versions-utils.tsx | 34 +++- .../__snapshots__/useAbilities.test.tsx.snap | 8 + .../alerting/unified/hooks/useAbilities.ts | 3 + .../alerting/unified/hooks/useCombinedRule.ts | 14 +- .../rule-editor/ExistingRuleEditor.tsx | 10 +- .../unified/rule-editor/RuleEditor.tsx | 2 +- public/locales/en-US/grafana.json | 13 +- public/locales/pseudo-LOCALE/grafana.json | 13 +- 24 files changed, 558 insertions(+), 175 deletions(-) create mode 100644 public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/ComparisonDrawer.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/ConfirmVersionRestoreModal.tsx rename public/app/features/alerting/unified/components/rule-viewer/tabs/{components => version-history}/UpdatedBy.tsx (96%) rename public/app/features/alerting/unified/components/rule-viewer/tabs/{components => version-history}/VersionHistoryTable.tsx (54%) create mode 100644 public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/useRestoreVersion.ts rename public/app/features/alerting/unified/components/rule-viewer/tabs/{ => version-history}/versions-utils.tsx (79%) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d84ae066775..f3b1da31b5c 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -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", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index feb0e5d5d4b..fc0b0c8dfb7 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -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 diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 569c2cbfc1d..3f5045123d9 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -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" } }, { diff --git a/public/app/core/components/VersionHistory/VersionHistoryComparison.tsx b/public/app/core/components/VersionHistory/VersionHistoryComparison.tsx index fd1bc5f9468..193c50d2233 100644 --- a/public/app/core/components/VersionHistory/VersionHistoryComparison.tsx +++ b/public/app/core/components/VersionHistory/VersionHistoryComparison.tsx @@ -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 = { * 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 ( Version {{ version }} updated by {{ createdBy }} ({{ ageString }}) {{ message }} @@ -54,6 +60,8 @@ export const VersionHistoryComparison = ({ oldVersion, newVersion, preprocessVersion = identity, + showRestoreButton = false, + onRestore, }: DiffViewProps) => { const diff = jsonDiff(preprocessVersion(oldVersion), preprocessVersion(newVersion)); const noHumanReadableDiffs = Object.entries(diff).length === 0; @@ -83,18 +91,28 @@ export const VersionHistoryComparison = ({ ))} - - {showJsonDiff && ( - - )} - {!showJsonDiff && ( - + {showRestoreButton && ( + )} - + {showJsonDiff && ( )} diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts index 9f3cf849cda..e546d215574 100644 --- a/public/app/features/alerting/unified/Analytics.ts +++ b/public/app/features/alerting/unified/Analytics.ts @@ -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'; diff --git a/public/app/features/alerting/unified/components/notification-policies/ContactPointSelector.tsx b/public/app/features/alerting/unified/components/notification-policies/ContactPointSelector.tsx index 69cb19ebee2..03eaff1c808 100644 --- a/public/app/features/alerting/unified/components/notification-policies/ContactPointSelector.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/ContactPointSelector.tsx @@ -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 ; @@ -71,6 +80,7 @@ export const ContactPointSelector = ({ value={matchedContactPoint} {...selectProps} isLoading={isLoading} + disabled={isLoading} /> {showRefreshButton && ( ; // 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(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) => {
e.preventDefault()} className={styles.form}>
+ {isManualRestore && ( + + + You are manually restoring an old version of this alert rule. Please review the changes carefully before + saving the rule definition. + + + )} {isPaused && } {/* Step 1 */} diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx index c3e9883f923..4c0cf9ea5aa 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx @@ -15,10 +15,20 @@ export interface ContactPointSelectorProps { } export function ContactPointSelector({ alertManager, onSelectContactPoint }: ContactPointSelectorProps) { - const { control, watch, trigger } = useFormContext(); + const { control, watch, trigger, setError } = useFormContext(); 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} /> diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx index 5faad744839..e714277c490 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx @@ -130,7 +130,7 @@ const RuleViewer = () => { {activeTab === ActiveTab.Routing && } {activeTab === ActiveTab.Details &&
} {activeTab === ActiveTab.VersionHistory && isGrafanaRulerRule(rule.rulerRule) && ( - + )} diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/AlertVersionHistory.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/AlertVersionHistory.tsx index abd042bc5b5..1018b04b5c9 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/AlertVersionHistory.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/AlertVersionHistory.tsx @@ -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 * 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>(); const [newVersion, setNewVersion] = useState>(); const [showDrawer, setShowDrawer] = useState(false); @@ -42,6 +57,42 @@ export function AlertVersionHistory({ ruleUid }: AlertVersionHistoryProps) { const [checkedVersions, setCheckedVersions] = useState(new Set()); 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 ( @@ -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, + newRule: RulerGrafanaRuleDTO + ) => { + 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 ( @@ -117,74 +179,33 @@ export function AlertVersionHistory({ ruleUid }: AlertVersionHistoryProps) { content={t('core.versionHistory.comparison.select', 'Select two versions to start comparing')} placement="bottom" > - {showDrawer && oldVersion && newVersion && ( - setShowDrawer(false)} - title={t('alerting.alertVersionHistory.comparing-versions', 'Comparing versions')} - > - - {config.featureToggles.alertingRuleVersionHistoryRestore && ( - - - - - - )} - + onRestoreSuccess('comparison-drawer')} + onRestoreError={(err: Error) => onRestoreFail('comparison-drawer', err)} + canRestore={canRestore} + /> )} - 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} /> ); } - -/** - * Turns a version of a Grafana rule definition into data structure - * used to display the version summary when comparing versions - */ -function parseVersionInfoToSummary(version: RulerGrafanaRuleDTO): 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, - }; -} diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx index 3faaa60744b..218e6cf0420 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx @@ -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', diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/ComparisonDrawer.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/ComparisonDrawer.tsx new file mode 100644 index 00000000000..940b107659d --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/ComparisonDrawer.tsx @@ -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; + newVersion: RulerGrafanaRuleDTO; + 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 ( + <> + setShowDrawer(false)} + title={t('alerting.alertVersionHistory.comparing-versions', 'Comparing versions')} + > + setShowConfirmModal(true)} + /> + + {showConfirmModal && ( + + )} + + ); +}; diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/ConfirmVersionRestoreModal.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/ConfirmVersionRestoreModal.tsx new file mode 100644 index 00000000000..95336c85016 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/ConfirmVersionRestoreModal.tsx @@ -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, 'isOpen' | 'onDismiss'> & { + isOpen: boolean; + baseVersion?: RulerGrafanaRuleDTO; + versionToRestore?: RulerGrafanaRuleDTO; + 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 ( + + {!error && ( + + Are you sure you want to restore the alert rule definition to this version? All unsaved changes will be + lost. + + )} + + + Summary of changes to be applied: + + +
+ {diff && Object.entries(diff).map(([key, diffs]) => )} +
+ {error && ( + + + Your alert rule could not be restored. This may be due to changes to other entities such as contact + points, data sources etc. Please manually restore the rule version + +
+                {stringifyErrorLike(error)}
+              
+
+ )} + + } + onConfirm={!error ? onRestoreConfirm : onManualRestore} + onDismiss={onDismiss} + /> + ); +}; diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/components/UpdatedBy.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/UpdatedBy.tsx similarity index 96% rename from public/app/features/alerting/unified/components/rule-viewer/tabs/components/UpdatedBy.tsx rename to public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/UpdatedBy.tsx index c391f13af83..095e7912ddb 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/components/UpdatedBy.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/UpdatedBy.tsx @@ -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'); diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/components/VersionHistoryTable.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/VersionHistoryTable.tsx similarity index 54% rename from public/app/features/alerting/unified/components/rule-viewer/tabs/components/VersionHistoryTable.tsx rename to public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/VersionHistoryTable.tsx index 29edbfcda20..e3f9e518e18 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/components/VersionHistoryTable.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/VersionHistoryTable.tsx @@ -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): void; ruleVersions: Array>; disableSelection: boolean; checkedVersions: Set; -}) { - //----> restore code : no need to review as it's behind a feature flag - const [confirmRestore, setConfirmRestore] = useState(false); - const [restoreDiff, setRestoreDiff] = useState(); + 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>(); + 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) => { + setShowConfirmModal(true); + setRuleToRestore(ruleToRestore); }; const hideConfirmation = () => { - setConfirmRestore(false); + setShowConfirmModal(false); }; - //----> end of restore code + const unknown = t('alerting.alertVersionHistory.unknown', 'Unknown'); - const columns: Array> = [ + const columns: Array>> = [ { 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 ( {isFirstItem ? ( - ) : config.featureToggles.alertingRuleVersionHistoryRestore ? ( - + ) : canRestore ? ( + <> + + + ) : null} ); @@ -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 */} - - - Are you sure you want to restore the alert rule definition to this version? All unsaved changes will be - lost. - - - - Summary of changes to be applied: - - -
- {restoreDiff && ( - <> - {Object.entries(restoreDiff).map(([key, diffs]) => ( - - ))} - - )} -
- - } - confirmText={'Yes, restore configuration'} - onConfirm={() => { - hideConfirmation(); - }} - onDismiss={() => hideConfirmation()} + - {/* ------------------------------------> END OF RESTORING CODE */} ); } diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/useRestoreVersion.ts b/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/useRestoreVersion.ts new file mode 100644 index 00000000000..ec6d6fdf31d --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/useRestoreVersion.ts @@ -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, + ruleWithLocation: RuleWithLocation + ) => { + 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, + }); + } + ); +} diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/versions-utils.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/versions-utils.tsx similarity index 79% rename from public/app/features/alerting/unified/components/rule-viewer/tabs/versions-utils.tsx rename to public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/versions-utils.tsx index b7ef95c8961..c1748cc1d04 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/versions-utils.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/versions-utils.tsx @@ -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): 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, + }; +} diff --git a/public/app/features/alerting/unified/hooks/__snapshots__/useAbilities.test.tsx.snap b/public/app/features/alerting/unified/hooks/__snapshots__/useAbilities.test.tsx.snap index 5a9da03ba29..45a27ea0e07 100644 --- a/public/app/features/alerting/unified/hooks/__snapshots__/useAbilities.test.tsx.snap +++ b/public/app/features/alerting/unified/hooks/__snapshots__/useAbilities.test.tsx.snap @@ -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, diff --git a/public/app/features/alerting/unified/hooks/useAbilities.ts b/public/app/features/alerting/unified/hooks/useAbilities.ts index f7a623a2c64..8bace1f93e9 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.ts +++ b/public/app/features/alerting/unified/hooks/useAbilities.ts @@ -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 { + 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(() => { diff --git a/public/app/features/alerting/unified/rule-editor/ExistingRuleEditor.tsx b/public/app/features/alerting/unified/rule-editor/ExistingRuleEditor.tsx index fe33ee855ec..00641db1eda 100644 --- a/public/app/features/alerting/unified/rule-editor/ExistingRuleEditor.tsx +++ b/public/app/features/alerting/unified/rule-editor/ExistingRuleEditor.tsx @@ -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; } -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 Sorry! You do not have permission to edit this rule.; } - return ; + return ; } diff --git a/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx index 42585600052..5c1859b5865 100644 --- a/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx @@ -61,7 +61,7 @@ const RuleEditor = () => { } if (identifier) { - return ; + return ; } if (copyFromIdentifier) { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index fe2fea8fc02..2447f89a826 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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.", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index d6af162cbf2..9bd26c9e45a 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -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ş.",