From 2014d27defe5668ba07913cec6f2186c90eaaab2 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Mon, 17 Feb 2025 13:25:32 +0100 Subject: [PATCH] Alerting: Add alert rule version history - part1 (#99490) * Add alertingRuleVersionHistory feature toggle * WIP: Add version history tab * revert temp change in index.ts * wip2 * --wip-- * sync code with the BE changes in the endpoint * add translations * Add translations * use ff only for restore feature * WIP: Add tracking, make version required, and start mapping dif results Co-authored-by: Tom Ratcliffe * Tweak more translations and improve types * Add button to show/hide JSON diff * update type for top level rule fields * Create types * Make updated_by/version properties optional * Update mocks to remove updated by and version * add comments to restore code * rename fetature flag, as we use this one only for the restore feature * Update version history to handle special cases * Add diff numbers * Fix conflicts * Move generic computeVersionDiff to a utils file * Update DOM structure of version summary and tidy up types * Add tests for version comparison logic * Lint fix utils file * Rename props and add docs * Change to EmptyState and log when no versions * Remove CreatedBy component and simplify * Add missing i18n for version history * add test for computeVersionDiff * update test * fix number diff order and add a test * fix prettier * fix prettier * Add promise resolve back in * Rename to humanReadableDiff and tweak translation * Show tab for recording rules as well * Split components out to separate files * Add optional interval seconds * Update i18n * Remove commented code * Remove value * Remove unneeded version * Consistent rendering of updated by * Mode parseVersionInfo to a separate pure function * update invalidate/provide tags for getAlertVersionHistory * Use checkedVersions state only in the parent component * update getSpecialUidMap name and create an interface * Fix prettier * update tab description * use set instead of map for checkedVersions --------- Co-authored-by: Tom Ratcliffe --- .../src/types/featureToggles.gen.ts | 1 + pkg/services/featuremgmt/registry.go | 9 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 18 ++ .../VersionHistoryComparison.tsx | 103 ++++++++++ .../features/alerting/unified/Analytics.ts | 11 + .../alerting/unified/api/alertRuleApi.ts | 24 ++- .../alerting/unified/api/alertingApi.ts | 1 + .../rule-viewer/RuleViewer.test.tsx | 73 ++++++- .../components/rule-viewer/RuleViewer.tsx | 14 ++ .../rule-viewer/tabs/AlertVersionHistory.tsx | 190 ++++++++++++++++++ .../components/rule-viewer/tabs/Details.tsx | 12 +- .../rule-viewer/tabs/components/UpdatedBy.tsx | 63 ++++++ .../tabs/components/VersionHistoryTable.tsx | 185 +++++++++++++++++ .../rule-viewer/tabs/versions-utils.tsx | 106 ++++++++++ .../components/settings/VersionManager.tsx | 31 +-- .../app/features/alerting/unified/mockApi.ts | 3 + .../mocks/server/handlers/grafanaRuler.ts | 61 ++++++ .../alerting/unified/utils/diff.test.ts | 58 ++++++ .../features/alerting/unified/utils/diff.ts | 37 ++++ .../alerting/unified/utils/query.test.ts | 1 + .../alerting/unified/utils/rule-form.test.ts | 2 + .../alerting/unified/utils/rule-id.test.tsx | 1 + .../fixtures/alertRules.fixture.ts | 1 + .../settings/version-history/DiffTitle.tsx | 8 +- public/app/types/unified-alerting-dto.ts | 17 +- public/locales/en-US/grafana.json | 54 +++++ public/locales/pseudo-LOCALE/grafana.json | 54 +++++ 29 files changed, 1089 insertions(+), 54 deletions(-) create mode 100644 public/app/core/components/VersionHistory/VersionHistoryComparison.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/tabs/AlertVersionHistory.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/tabs/components/UpdatedBy.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/tabs/components/VersionHistoryTable.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/tabs/versions-utils.tsx create mode 100644 public/app/features/alerting/unified/utils/diff.test.ts create mode 100644 public/app/features/alerting/unified/utils/diff.ts diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 6fe30533f7b..cc9f02f7eff 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -258,4 +258,5 @@ export interface FeatureToggles { grafanaconThemes?: boolean; pluginsCDNSyncLoader?: boolean; alertingJiraIntegration?: boolean; + alertingRuleVersionHistoryRestore?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 5b2ff8583e2..3f47370a2cd 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1801,6 +1801,15 @@ var ( FrontendOnly: true, HideFromDocs: true, }, + { + Name: "alertingRuleVersionHistoryRestore", + Description: "Enables the alert rule version history restore feature", + FrontendOnly: true, + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromAdminPage: true, + HideFromDocs: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 91c27f35805..6c44faea5f5 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -239,3 +239,4 @@ 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 diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 62ca16ff004..29dd0cf91e1 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -966,4 +966,8 @@ const ( // FlagAlertingJiraIntegration // Enables the new Jira integration for contact points in cloud alert managers. FlagAlertingJiraIntegration = "alertingJiraIntegration" + + // FlagAlertingRuleVersionHistoryRestore + // Enables the alert rule version history restore feature + FlagAlertingRuleVersionHistoryRestore = "alertingRuleVersionHistoryRestore" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 8ee5f1785ca..662fb67dcf4 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -401,6 +401,24 @@ "expression": "false" } }, + { + "metadata": { + "name": "alertingRuleVersionHistoryRestore", + "resourceVersion": "1738831836776", + "creationTimestamp": "2025-01-16T14:08:12Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-02-06 08:50:36.776739 +0000 UTC" + } + }, + "spec": { + "description": "Enables the alert rule version history restore feature", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "frontend": true, + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "alertingSaveStateCompressed", diff --git a/public/app/core/components/VersionHistory/VersionHistoryComparison.tsx b/public/app/core/components/VersionHistory/VersionHistoryComparison.tsx new file mode 100644 index 00000000000..fd1bc5f9468 --- /dev/null +++ b/public/app/core/components/VersionHistory/VersionHistoryComparison.tsx @@ -0,0 +1,103 @@ +import { identity } from 'lodash'; +import { useState } from 'react'; + +import { dateTimeFormatTimeAgo } from '@grafana/data'; +import { Box, Button, Divider, EmptyState, Icon, Stack, Text } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; +import { DiffGroup } from 'app/features/dashboard-scene/settings/version-history/DiffGroup'; +import { DiffViewer } from 'app/features/dashboard-scene/settings/version-history/DiffViewer'; +import { jsonDiff } from 'app/features/dashboard-scene/settings/version-history/utils'; + +/** Meta information about a version of an entity */ +export interface RevisionModel { + version: number | string; + /** When was this version created? */ + created: string; + /** Who created/edited this version? */ + createdBy: string; + /** Optional message describing change encapsulated in this version */ + message?: string; +} + +type DiffArgument = Parameters[0]; + +type DiffViewProps = { + /** Information to help summarise the change in the newer version */ + newSummary: RevisionModel; + /** Information to help summarise the change in the older version */ + oldSummary: RevisionModel; + /** The actual data model of the older version */ + oldVersion: T; + /** The actual data model of the newer version */ + newVersion: T; + /** + * Helper method to tweak the calculated diff for the human readable output. + * + * e.g. mapping machine IDs to translated names, removing fields that the user can't control anyway etc. + */ + preprocessVersion?: (version: T) => DiffArgument; +}; + +const VersionChangeSummary = ({ info }: { info: RevisionModel }) => { + const { created, createdBy, version, message = '' } = info; + const ageString = dateTimeFormatTimeAgo(created); + return ( + + Version {{ version }} updated by {{ createdBy }} ({{ ageString }}) {{ message }} + + ); +}; + +export const VersionHistoryComparison = ({ + oldSummary, + newSummary, + oldVersion, + newVersion, + preprocessVersion = identity, +}: DiffViewProps) => { + const diff = jsonDiff(preprocessVersion(oldVersion), preprocessVersion(newVersion)); + const noHumanReadableDiffs = Object.entries(diff).length === 0; + const [showJsonDiff, setShowJsonDiff] = useState(noHumanReadableDiffs); + + return ( + + + + + + + + + + {noHumanReadableDiffs && ( + + View JSON diff to see all changes + + )} + {Object.entries(diff).map(([key, diffs]) => ( + + ))} + + + + {showJsonDiff && ( + + )} + {!showJsonDiff && ( + + )} + + {showJsonDiff && ( + + )} + + ); +}; diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts index a5177c8fd59..e873fe6abd2 100644 --- a/public/app/features/alerting/unified/Analytics.ts +++ b/public/app/features/alerting/unified/Analytics.ts @@ -28,6 +28,7 @@ export const LogMessages = { grafanaRecording: 'creating Grafana recording rule from scratch', loadedCentralAlertStateHistory: 'loaded central alert state history', exportNewGrafanaRule: 'exporting new Grafana rule', + noAlertRuleVersionsFound: 'no alert rule versions found', }; const { logInfo, logError, logMeasurement, logWarning } = createMonitoringLogger('features.alerting', { @@ -214,6 +215,16 @@ export const trackInsightsFeedback = async (props: { useful: boolean; panel: str reportInteraction('grafana_alerting_insights', { ...defaults, ...props }); }; +interface RuleVersionComparisonProps { + latest: boolean; + oldVersion: number; + newVersion: number; +} + +export const trackRuleVersionsComparisonClick = async (payload: RuleVersionComparisonProps) => { + reportInteraction('grafana_alerting_rule_versions_comparison_click', { ...payload }); +}; + interface RulesSearchInteractionPayload { filter: string; triggeredBy: 'typing' | 'component'; diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts index 806b782a2bf..9c792b5cbc9 100644 --- a/public/app/features/alerting/unified/api/alertRuleApi.ts +++ b/public/app/features/alerting/unified/api/alertRuleApi.ts @@ -329,21 +329,29 @@ export const alertRuleApi = alertingApi.injectEndpoints({ }, }; }, - invalidatesTags: (result, _error, { namespace, payload, rulerConfig }) => [ - { type: 'RuleNamespace', id: `${rulerConfig.dataSourceUid}/${namespace}` }, - { type: 'RuleGroup', id: `${rulerConfig.dataSourceUid}/${namespace}/${payload.name}` }, - ...payload.rules - .filter((rule) => isGrafanaRulerRule(rule)) - .map((rule) => ({ type: 'GrafanaRulerRule', id: rule.grafana_alert.uid }) as const), - ], - }), + invalidatesTags: (result, _error, { namespace, payload, rulerConfig }) => { + const grafanaRulerRules = payload.rules.filter((rule) => isGrafanaRulerRule(rule)); + return [ + { type: 'RuleNamespace', id: `${rulerConfig.dataSourceUid}/${namespace}` }, + { type: 'RuleGroup', id: `${rulerConfig.dataSourceUid}/${namespace}/${payload.name}` }, + ...grafanaRulerRules.flatMap((rule) => [ + { type: 'GrafanaRulerRule', id: rule.grafana_alert.uid } as const, + { type: 'GrafanaRulerRuleVersion', id: rule.grafana_alert.uid } as const, + ]), + ]; + }, + }), getAlertRule: build.query({ // TODO: In future, if supported in other rulers, parametrize ruler source name // For now, to make the consumption of this hook clearer, only support Grafana ruler query: ({ uid }) => ({ url: `/api/ruler/${GRAFANA_RULES_SOURCE_NAME}/api/v1/rule/${uid}` }), providesTags: (_result, _error, { uid }) => [{ type: 'GrafanaRulerRule', id: uid }], }), + getAlertVersionHistory: build.query({ + query: ({ uid }) => ({ url: `/api/ruler/${GRAFANA_RULES_SOURCE_NAME}/api/v1/rule/${uid}/versions` }), + providesTags: (_result, _error, { uid }) => [{ type: 'GrafanaRulerRuleVersion', id: uid }], + }), exportRules: build.query({ query: ({ format, folderUid, group, ruleUid }) => ({ diff --git a/public/app/features/alerting/unified/api/alertingApi.ts b/public/app/features/alerting/unified/api/alertingApi.ts index 628aa0caf9d..becd84d297c 100644 --- a/public/app/features/alerting/unified/api/alertingApi.ts +++ b/public/app/features/alerting/unified/api/alertingApi.ts @@ -120,6 +120,7 @@ export const alertingApi = createApi({ 'GrafanaLabels', 'CombinedAlertRule', 'GrafanaRulerRule', + 'GrafanaRulerRuleVersion', 'GrafanaSlo', 'RuleGroup', 'RuleNamespace', diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx index 3bcff286676..802be0d3e10 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx @@ -193,6 +193,75 @@ describe('RuleViewer', () => { grafanaRulerRule.grafana_alert.title ); }); + + describe('version history', () => { + it('renders version history tab, and the compare version is enabled only when we have 2 versions selected', async () => { + const { user } = await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory); + + expect(await screen.findByRole('button', { name: /Compare versions/i })).toBeDisabled(); + + expect(screen.getAllByRole('row')).toHaveLength(7); + expect(screen.getAllByRole('row')[1]).toHaveTextContent(/6Provisioning2025-01-18 04:35:17/i); + expect(screen.getAllByRole('row')[1]).toHaveTextContent('+3-3Latest'); + + expect(screen.getAllByRole('row')[2]).toHaveTextContent(/5Alerting2025-01-17 04:35:17/i); + expect(screen.getAllByRole('row')[2]).toHaveTextContent('+5-5'); + + expect(screen.getAllByRole('row')[3]).toHaveTextContent(/4different user2025-01-16 04:35:17/i); + expect(screen.getAllByRole('row')[3]).toHaveTextContent('+5-5'); + + expect(screen.getAllByRole('row')[4]).toHaveTextContent(/3user12025-01-15 04:35:17/i); + expect(screen.getAllByRole('row')[4]).toHaveTextContent('+5-9'); + + expect(screen.getAllByRole('row')[5]).toHaveTextContent(/2User ID foo2025-01-14 04:35:17/i); + expect(screen.getAllByRole('row')[5]).toHaveTextContent('+11-7'); + + expect(screen.getAllByRole('row')[6]).toHaveTextContent(/1Unknown 2025-01-13 04:35:17/i); + + await user.click(screen.getByLabelText('1')); + await user.click(screen.getByLabelText('2')); + expect(await screen.findByRole('button', { name: /Compare versions/i })).toBeEnabled(); + await user.click(screen.getByLabelText('1')); + expect(await screen.findByRole('button', { name: /Compare versions/i })).toBeDisabled(); + }); + it('shows version history with special case `updated_by` values', async () => { + await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory); + expect(await screen.findByRole('button', { name: /Compare versions/i })).toBeDisabled(); + + expect(screen.getByRole('cell', { name: /provisioning/i })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: /alerting/i })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: /Unknown/i })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: /user id foo/i })).toBeInTheDocument(); + }); + + it('shows comparison of versions', async () => { + const { user } = await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory); + expect(await screen.findByRole('button', { name: /Compare versions/i })).toBeDisabled(); + + await user.click(screen.getByLabelText('1')); + await user.click(screen.getByLabelText('2')); + await user.click(screen.getByRole('button', { name: /Compare versions/i })); + await screen.findByText(/comparing versions/i); + expect(await screen.findByText(/pending period/i)).toBeInTheDocument(); + expect(screen.getAllByTestId('diffGroup')[0]).toHaveTextContent(/pending period changed 5m2h/i); + expect(screen.getAllByTestId('diffGroup')[1]).toHaveTextContent(/labels added foo bar/i); + expect(screen.getAllByTestId('diffGroup')[2]).toHaveTextContent(/contact point routing added/i); + }); + + it('renders version summary correctly for special cases', async () => { + const { user } = await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory); + expect(await screen.findByRole('button', { name: /Compare versions/i })).toBeDisabled(); + + await user.click(screen.getByLabelText('6')); + await user.click(screen.getByLabelText('5')); + await user.click(screen.getByRole('button', { name: /Compare versions/i })); + await screen.findByText(/comparing versions/i); + + const versionSummary = screen.getByRole('heading', { level: 4 }); + expect(versionSummary).toHaveTextContent(/Version 5 updated by alerting/i); + expect(versionSummary).toHaveTextContent(/Version 6 updated by provisioning/i); + }); + }); }); describe('Data source managed alert rule', () => { @@ -298,7 +367,7 @@ describe('RuleViewer', () => { const renderRuleViewer = async (rule: CombinedRule, identifier: RuleIdentifier, tab: ActiveTab = ActiveTab.Query) => { const path = `/alerting/${identifier.ruleSourceName}/${stringifyIdentifier(identifier)}/view?tab=${tab}`; - render( + const view = render( , @@ -306,6 +375,8 @@ const renderRuleViewer = async (rule: CombinedRule, identifier: RuleIdentifier, ); await waitFor(() => expect(ELEMENTS.loading.query()).not.toBeInTheDocument()); + + return view; }; jest.mock('@grafana/runtime', () => ({ 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 f1d3064fadb..5faad744839 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx @@ -42,6 +42,7 @@ import { FederatedRuleWarning } from './FederatedRuleWarning'; import PausedBadge from './PausedBadge'; import { useAlertRule } from './RuleContext'; import { RecordingBadge, StateBadge } from './StateBadges'; +import { AlertVersionHistory } from './tabs/AlertVersionHistory'; import { Details } from './tabs/Details'; import { History } from './tabs/History'; import { InstancesList } from './tabs/Instances'; @@ -54,6 +55,7 @@ export enum ActiveTab { History = 'history', Routing = 'routing', Details = 'details', + VersionHistory = 'version-history', } const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary(); @@ -127,6 +129,9 @@ const RuleViewer = () => { {activeTab === ActiveTab.History && isGrafanaRulerRule(rule.rulerRule) && } {activeTab === ActiveTab.Routing && } {activeTab === ActiveTab.Details &&
} + {activeTab === ActiveTab.VersionHistory && isGrafanaRulerRule(rule.rulerRule) && ( + + )} {duplicateRuleIdentifier && ( @@ -338,6 +343,7 @@ function usePageNav(rule: CombinedRule) { const groupName = rule.group.name; const isGrafanaAlertRule = isGrafanaRulerRule(rulerRule) && isAlertType; + const grafanaRecordingRule = isGrafanaRecordingRule(rulerRule); const isRecordingRuleType = isRecordingRule(promRule); const pageNav: NavModelItem = { @@ -377,6 +383,14 @@ function usePageNav(rule: CombinedRule) { setActiveTab(ActiveTab.Details); }, }, + { + text: 'Versions', + active: activeTab === ActiveTab.VersionHistory, + onClick: () => { + setActiveTab(ActiveTab.VersionHistory); + }, + hideFromTabs: !isGrafanaAlertRule && !grafanaRecordingRule, + }, ], parentItem: { text: groupName, 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 new file mode 100644 index 00000000000..abd042bc5b5 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/AlertVersionHistory.tsx @@ -0,0 +1,190 @@ +import { 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 { Trans, t } from 'app/core/internationalization'; +import { GrafanaRuleDefinition, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; + +import { LogMessages, logInfo, trackRuleVersionsComparisonClick } from '../../../Analytics'; +import { alertRuleApi } from '../../../api/alertRuleApi'; +import { stringifyErrorLike } from '../../../utils/misc'; + +import { VersionHistoryTable } from './components/VersionHistoryTable'; +import { getSpecialUidsDisplayMap, preprocessRuleForDiffDisplay } from './versions-utils'; + +const { useGetAlertVersionHistoryQuery } = alertRuleApi; + +interface AlertVersionHistoryProps { + ruleUid: string; +} + +/** List of (top level) properties to exclude from being shown in human readable summary of version changes */ +export const grafanaAlertPropertiesToIgnore: Array = [ + 'id', + 'uid', + 'updated', + 'updated_by', + 'version', +]; + +/** + * 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) { + const { isLoading, currentData: ruleVersions = [], error } = useGetAlertVersionHistoryQuery({ uid: ruleUid }); + + const [oldVersion, setOldVersion] = useState>(); + const [newVersion, setNewVersion] = useState>(); + const [showDrawer, setShowDrawer] = useState(false); + // checked versions for comparison. key is the version number, value is whether it's checked + const [checkedVersions, setCheckedVersions] = useState(new Set()); + const canCompare = useMemo(() => checkedVersions.size > 1, [checkedVersions]); + + if (error) { + return ( + + {stringifyErrorLike(error)} + + ); + } + + if (isLoading) { + return ; + } + + if (!ruleVersions.length) { + // We don't expect this to happen - all alert rules _should_ have at least one version + logInfo(LogMessages.noAlertRuleVersionsFound, { ruleUid }); + return ( + + ); + } + + const compareVersions = () => { + const [older, newer] = ruleVersions + .filter((rule) => { + const version = rule.grafana_alert.version; + if (!version && version !== 0) { + return; + } + return checkedVersions.has(String(rule.grafana_alert.version)); + }) + .sort((a, b) => { + const aVersion = a.grafana_alert.version; + const bVersion = b.grafana_alert.version; + if (aVersion === undefined || bVersion === undefined) { + return 0; + } + return aVersion - bVersion; + }); + + trackRuleVersionsComparisonClick({ + latest: newer === ruleVersions[0], + oldVersion: older?.grafana_alert.version || 0, + newVersion: newer?.grafana_alert.version || 0, + }); + + setOldVersion(older); + setNewVersion(newer); + setShowDrawer(true); + }; + + function handleCheckedVersionChange(id: string) { + setCheckedVersions((prevState) => { + const newState = new Set(prevState); + newState.has(id) ? newState.delete(id) : newState.add(id); + return newState; + }); + setOldVersion(undefined); + setNewVersion(undefined); + } + + return ( + + + + Each time you edit the alert rule, a new version is created. Select two versions below and compare their + differences. + + + + + + + + {showDrawer && oldVersion && newVersion && ( + setShowDrawer(false)} + title={t('alerting.alertVersionHistory.comparing-versions', 'Comparing versions')} + > + + {config.featureToggles.alertingRuleVersionHistoryRestore && ( + + + + + + )} + + )} + + + + ); +} + +/** + * 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 63d3933a4a8..3faaa60744b 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,6 +18,8 @@ import { isNullDate } from '../../../utils/time'; import { Tokenize } from '../../Tokenize'; import { DetailText } from '../../common/DetailText'; +import { UpdatedByUser } from './components/UpdatedBy'; + enum RuleType { GrafanaManagedAlertRule = 'Grafana-managed alert rule', GrafanaManagedRecordingRule = 'Grafana-managed recording rule', @@ -65,14 +67,6 @@ export const Details = ({ rule }: DetailsProps) => { const hasEvaluationDuration = Number.isFinite(evaluationDuration); - const lastUpdatedBy = (() => { - if (!isGrafanaRulerRule(rule.rulerRule)) { - return null; - } - - return rule.rulerRule.grafana_alert.updated_by?.name || `User ID: ${rule.rulerRule.grafana_alert.updated_by?.uid}`; - })(); - const updated = isGrafanaRulerRule(rule.rulerRule) ? rule.rulerRule.grafana_alert.updated : undefined; const isPaused = isGrafanaAlertingRule(rule.rulerRule) && rule.rulerRule.grafana_alert.is_paused; const pausedIcon = ( @@ -102,7 +96,7 @@ export const Details = ({ rule }: DetailsProps) => { } /> {updated && ( { + const unknown = t('alerting.alertVersionHistory.unknown', 'Unknown'); + const SPECIAL_UID_MAP = getSpecialUidsDisplayMap(); + const styles = useStyles2(getStyles); + + const unknownCase = ( + + + {unknown} + + + + ); + if (!user) { + return unknownCase; + } + const specialCase = SPECIAL_UID_MAP[user.uid]; + if (specialCase || !user) { + return ( + + + + + + ); + } + if (user.name) { + return user.name; + } + if (user.uid) { + return t('alerting.alertVersionHistory.user-id', 'User ID {{uid}}', { uid: user.uid }); + } + return unknownCase; +}; + +const getStyles = () => { + return { + badge: css({ cursor: 'help' }), + underline: css({ + textDecoration: 'underline dotted', + textUnderlineOffset: '5px', + cursor: 'help', + }), + }; +}; 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/components/VersionHistoryTable.tsx new file mode 100644 index 00000000000..29edbfcda20 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/components/VersionHistoryTable.tsx @@ -0,0 +1,185 @@ +import { 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 { Trans, t } from 'app/core/internationalization'; +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 { GrafanaRuleDefinition, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; + +import { UpdatedByUser } from './UpdatedBy'; + +const VERSIONS_PAGE_SIZE = 20; + +export function VersionHistoryTable({ + onVersionsChecked, + ruleVersions, + disableSelection, + checkedVersions, +}: { + onVersionsChecked(id: string): 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(); + + 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 hideConfirmation = () => { + setConfirmRestore(false); + }; + //----> end of restore code + const unknown = t('alerting.alertVersionHistory.unknown', 'Unknown'); + + const columns: Array> = [ + { + disableGrow: true, + id: 'id', + header: t('core.versionHistory.table.version', 'Version'), + cell: ({ row }) => { + const id = String(row.original.grafana_alert.version); + const thisValue = checkedVersions.has(String(id ?? false)) ?? false; + return ( + + { + onVersionsChecked(id); + }} + /> + + ); + }, + }, + { + id: 'createdBy', + header: t('core.versionHistory.table.updatedBy', 'Updated By'), + disableGrow: true, + cell: ({ row }) => { + return ; + }, + }, + { + id: 'created', + header: t('core.versionHistory.table.updated', 'Date'), + disableGrow: true, + cell: ({ row }) => { + const value = row.original.grafana_alert.updated; + if (!value) { + return unknown; + } + return dateTimeFormat(value) + ' (' + dateTimeFormatTimeAgo(value) + ')'; + }, + }, + { + id: 'diff', + disableGrow: true, + cell: ({ rows, row }) => { + const isLastItem = row.index === ruleVersions.length - 1; + + const prevVersion = isLastItem ? {} : rows[row.index + 1]?.original; + const currentVersion = row.original; + const diff = computeVersionDiff(prevVersion, currentVersion); + + const added = `+${diff.added}`; + const removed = `-${diff.removed}`; + return ( + + + {added} + + + {removed} + + + ); + }, + }, + { + id: 'actions', + disableGrow: true, + cell: ({ row }) => { + const isFirstItem = row.index === 0; + + return ( + + {isFirstItem ? ( + + ) : config.featureToggles.alertingRuleVersionHistoryRestore ? ( + + ) : null} + + ); + }, + }, + ]; + + return ( + <> + `${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/versions-utils.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/versions-utils.tsx new file mode 100644 index 00000000000..b7ef95c8961 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/versions-utils.tsx @@ -0,0 +1,106 @@ +import { IconName } from '@grafana/data'; +import { BadgeColor } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; +import { + GrafanaAlertRuleDTOField, + GrafanaRuleDefinition, + RulerGrafanaRuleDTO, + TopLevelGrafanaRuleDTOField, +} from 'app/types/unified-alerting-dto'; + +import { grafanaAlertPropertiesToIgnore } from './AlertVersionHistory'; + +interface SpecialUidsDisplayMapEntry { + name: string; + tooltipContent: string; + badgeColor: BadgeColor; + icon?: IconName; +} + +/** + * Gets a map of special case UIDs that we should display differently. + * Used for mapping cases where provisioning or the alerting system is listed as responsible for a version history entry. + */ + +export const getSpecialUidsDisplayMap: () => Record = () => { + const provisioning = { + name: t('alerting.alertVersionHistory.provisioning', 'Provisioning'), + tooltipContent: t( + 'alerting.alertVersionHistory.provisioning-change-description', + 'Version update was made via provisioning' + ), + badgeColor: 'purple', + } as const; + + return { + __alerting__: { + name: t('alerting.alertVersionHistory.alerting', 'Alerting'), + tooltipContent: t( + 'alerting.alertVersionHistory.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' + ), + badgeColor: 'orange', + icon: 'bell', + }, + service: provisioning, + __provisioning__: provisioning, + }; +}; +/** + * Flattens a GMA rule and turns properties into human readable/translated strings, for use when computing diffs + * and displaying in version comparisons + */ +export function preprocessRuleForDiffDisplay(rulerRule: RulerGrafanaRuleDTO) { + const { grafana_alert, ...rest } = rulerRule; + + /** Translations for top level properties of alert, other than `grafana_alert` */ + const translationMap: Partial> = { + for: t('alerting.alertVersionHistory.pendingPeriod', 'Pending period'), + annotations: t('alerting.alertVersionHistory.annotations', 'Annotations'), + labels: t('alerting.alertVersionHistory.labels', 'Labels'), + }; + + /** Translation map for other properties within `grafana_alert` */ + const grafanaAlertTranslationMap: Partial> = { + title: t('alerting.alertVersionHistory.name', 'Name'), + namespace_uid: t('alerting.alertVersionHistory.namespace_uid', 'Folder UID'), + data: t('alerting.alertVersionHistory.queryAndAlertCondition', 'Query and alert condition'), + notification_settings: t('alerting.alertVersionHistory.contactPointRouting', 'Contact point routing'), + no_data_state: t('alerting.alertVersionHistory.noDataState', 'Alert state when no data'), + exec_err_state: t('alerting.alertVersionHistory.execErrorState', 'Alert state when execution error'), + is_paused: t('alerting.alertVersionHistory.paused', 'Paused state'), + rule_group: t('alerting.alertVersionHistory.rule_group', 'Rule group'), + condition: t('alerting.alertVersionHistory.condition', 'Alert condition'), + intervalSeconds: t('alerting.alertVersionHistory.intervalSeconds', 'Evaluation interval'), + }; + + const processedTopLevel = Object.entries(rest).reduce((acc, [key, value]) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const topLevelRuleKey = key as keyof Omit; + const potentiallyTranslatedKey = translationMap[topLevelRuleKey] || key; + return { + ...acc, + [potentiallyTranslatedKey]: value, + }; + }, {}); + + const processedGrafanaAlert = Object.entries(grafana_alert).reduce((acc, [key, value]) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const grafanaRuleKey = key as keyof GrafanaRuleDefinition; + + if (grafanaAlertPropertiesToIgnore.includes(grafanaRuleKey)) { + return acc; + } + + const potentiallyTranslatedKey = grafanaAlertTranslationMap[grafanaRuleKey] || key; + return { + ...acc, + [potentiallyTranslatedKey]: value, + }; + }, {}); + + return { + ...processedTopLevel, + ...processedGrafanaAlert, + }; +} diff --git a/public/app/features/alerting/unified/components/settings/VersionManager.tsx b/public/app/features/alerting/unified/components/settings/VersionManager.tsx index e7a122e0441..51f9a9f4333 100644 --- a/public/app/features/alerting/unified/components/settings/VersionManager.tsx +++ b/public/app/features/alerting/unified/components/settings/VersionManager.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { chain, omit } from 'lodash'; +import { omit } from 'lodash'; import moment from 'moment'; import { useState } from 'react'; @@ -17,10 +17,10 @@ import { useStyles2, } from '@grafana/ui'; import { DiffViewer } from 'app/features/dashboard-scene/settings/version-history/DiffViewer'; -import { jsonDiff } from 'app/features/dashboard-scene/settings/version-history/utils'; import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; import { alertmanagerApi } from '../../api/alertmanagerApi'; +import { computeVersionDiff } from '../../utils/diff'; import { stringifyErrorLike } from '../../utils/misc'; import { Spacer } from '../Spacer'; @@ -98,7 +98,7 @@ const AlertmanagerConfigurationVersionManager = ({ return { ...config, - diff: priorConfig ? computeConfigDiff(config, latestConfig) : { added: 0, removed: 0 }, + diff: priorConfig ? computeVersionDiff(config, latestConfig, normalizeConfig) : { added: 0, removed: 0 }, }; }); @@ -296,29 +296,4 @@ function normalizeConfig(config: AlertManagerCortexConfig) { return omit(config, ['id', 'last_applied']); } -function computeConfigDiff(json1: AlertManagerCortexConfig, json2: AlertManagerCortexConfig): Diff { - const cleanedJson1 = normalizeConfig(json1); - const cleanedJson2 = normalizeConfig(json2); - - const diff = jsonDiff(cleanedJson1, cleanedJson2); - const added = chain(diff) - .values() - .flatMap() - .filter((operation) => operation.op === 'add' || operation.op === 'replace' || operation.op === 'move') - .sumBy((operation) => operation.endLineNumber - operation.startLineNumber + 1) - .value(); - - const removed = chain(diff) - .values() - .flatMap() - .filter((operation) => operation.op === 'remove' || operation.op === 'replace') - .sumBy((operation) => operation.endLineNumber - operation.startLineNumber + 1) - .value(); - - return { - added, - removed, - }; -} - export { AlertmanagerConfigurationVersionManager }; diff --git a/public/app/features/alerting/unified/mockApi.ts b/public/app/features/alerting/unified/mockApi.ts index 2ff26834dee..3de7de4e7d9 100644 --- a/public/app/features/alerting/unified/mockApi.ts +++ b/public/app/features/alerting/unified/mockApi.ts @@ -184,6 +184,9 @@ export function mockAlertRuleApi(server: SetupServer) { getAlertRule: (uid: string, response: RulerGrafanaRuleDTO) => { server.use(http.get(`/api/ruler/grafana/api/v1/rule/${uid}`, () => HttpResponse.json(response))); }, + getAlertRuleVersionHistory: (uid: string, response: RulerGrafanaRuleDTO[]) => { + server.use(http.get(`/api/ruler/grafana/api/v1/rule/${uid}/versions`, () => HttpResponse.json(response))); + }, }; } diff --git a/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts b/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts index 6dfebdcd151..bafb6f44d82 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts @@ -1,8 +1,10 @@ +import { produce } from 'immer'; import { HttpResponse, delay, http } from 'msw'; export const MOCK_GRAFANA_ALERT_RULE_TITLE = 'Test alert'; import { + GrafanaRuleDefinition, PromRulesResponse, RulerGrafanaRuleDTO, RulerRuleGroupDTO, @@ -135,6 +137,64 @@ export const rulerRuleHandler = () => { }); }; +export const rulerRuleVersionHistoryHandler = () => { + const grafanaRuleVersions = [ + produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO) => { + draft.grafana_alert.version = 6; + draft.grafana_alert.updated = '2025-01-18T09:35:17.000Z'; + draft.grafana_alert.updated_by = { + uid: 'service', + name: '', + }; + }), + produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO) => { + draft.grafana_alert.version = 5; + draft.grafana_alert.updated = '2025-01-17T09:35:17.000Z'; + draft.grafana_alert.updated_by = { + uid: '__alerting__', + name: '', + }; + }), + produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO) => { + draft.grafana_alert.version = 4; + draft.grafana_alert.title = 'Some new title'; + draft.grafana_alert.updated = '2025-01-16T09:35:17.000Z'; + draft.grafana_alert.updated_by = { + uid: 'different', + name: 'different user', + }; + }), + produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO) => { + draft.grafana_alert.version = 3; + draft.grafana_alert.updated = '2025-01-15T09:35:17.000Z'; + draft.grafana_alert.updated_by = { + uid: '1', + name: 'user1', + }; + }), + produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO) => { + draft.grafana_alert.version = 2; + draft.grafana_alert.updated = '2025-01-14T09:35:17.000Z'; + draft.for = '2h'; + draft.labels.foo = 'bar'; + draft.grafana_alert.notification_settings = { receiver: 'another receiver' }; + draft.grafana_alert.updated_by = { + uid: 'foo', + name: '', + }; + }), + produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO) => { + draft.grafana_alert.version = 1; + draft.grafana_alert.updated = '2025-01-13T09:35:17.000Z'; + draft.grafana_alert.updated_by = null; + }), + ]; + + return http.get<{ uid: string }>(`/api/ruler/grafana/api/v1/rule/:uid/versions`, ({ params: { uid } }) => { + return HttpResponse.json(grafanaRuleVersions); + }); +}; + export const historyHandler = () => { return http.get('/api/v1/rules/history', () => { return HttpResponse.json(getHistoryResponse([time_0, time_0, time_plus_30, time_plus_30])); @@ -150,5 +210,6 @@ const handlers = [ historyHandler(), updateRulerRuleNamespaceHandler(), deleteRulerRuleGroupHandler(), + rulerRuleVersionHistoryHandler(), ]; export default handlers; diff --git a/public/app/features/alerting/unified/utils/diff.test.ts b/public/app/features/alerting/unified/utils/diff.test.ts new file mode 100644 index 00000000000..c8307620427 --- /dev/null +++ b/public/app/features/alerting/unified/utils/diff.test.ts @@ -0,0 +1,58 @@ +import { computeVersionDiff } from './diff'; + +describe('computeVersionDiff', () => { + it('should compute the correct diff for added and removed lines', () => { + const json1 = { a: 1, b: 2 }; + const json2 = { a: 1, b: 3, c: 4 }; + + const result = computeVersionDiff(json1, json2); + + expect(result.added).toBe(2); + expect(result.removed).toBe(1); + }); + + it('should handle empty objects', () => { + const json1 = {}; + const json2 = {}; + + const result = computeVersionDiff(json1, json2); + + expect(result.added).toBe(0); + expect(result.removed).toBe(0); + }); + + it('should handle nested objects', () => { + const json1 = { a: { b: 1 } }; + const json2 = { a: { b: 2, c: 4 } }; + + const result = computeVersionDiff(json1, json2); + + expect(result.added).toBe(2); + expect(result.removed).toBe(1); + }); + + it('should handle arrays', () => { + const json1 = { a: [1, 2, 3], b: 2 }; + const json2 = { a: [1, 2, 4] }; + + const result = computeVersionDiff(json1, json2); + + expect(result.added).toBe(1); + expect(result.removed).toBe(2); + }); + + it('should use normalizeFunction to normalize input objects', () => { + const json1 = { a: 1, b: 2 }; + const json2 = { a: 1, b: 3, c: 4 }; + + const normalizeFunction = (item: typeof json1) => { + const { b, ...rest } = item; + return rest; + }; + + const result = computeVersionDiff(json1, json2, normalizeFunction); + + expect(result.added).toBe(1); + expect(result.removed).toBe(0); + }); +}); diff --git a/public/app/features/alerting/unified/utils/diff.ts b/public/app/features/alerting/unified/utils/diff.ts new file mode 100644 index 00000000000..721107123db --- /dev/null +++ b/public/app/features/alerting/unified/utils/diff.ts @@ -0,0 +1,37 @@ +import { chain, identity } from 'lodash'; + +import { jsonDiff } from 'app/features/dashboard-scene/settings/version-history/utils'; + +export type Diff = { + added: number; + removed: number; +}; + +export function computeVersionDiff( + json1: T, + json2: T, + normalizeFunction: (item: T) => Object = identity +): Diff { + const cleanedJson1 = normalizeFunction(json1); + const cleanedJson2 = normalizeFunction(json2); + + const diff = jsonDiff(cleanedJson1, cleanedJson2); + const added = chain(diff) + .values() + .flatMap() + .filter((operation) => operation.op === 'add' || operation.op === 'replace' || operation.op === 'move') + .sumBy((operation) => operation.endLineNumber - operation.startLineNumber + 1) + .value(); + + const removed = chain(diff) + .values() + .flatMap() + .filter((operation) => operation.op === 'remove' || operation.op === 'replace') + .sumBy((operation) => operation.endLineNumber - operation.startLineNumber + 1) + .value(); + + return { + added, + removed, + }; +} diff --git a/public/app/features/alerting/unified/utils/query.test.ts b/public/app/features/alerting/unified/utils/query.test.ts index 9b02aac176b..718d83418ab 100644 --- a/public/app/features/alerting/unified/utils/query.test.ts +++ b/public/app/features/alerting/unified/utils/query.test.ts @@ -93,6 +93,7 @@ const grafanaAlert = { no_data_state: GrafanaAlertStateDecision.NoData, title: 'Test alert', uid: 'asdf23', + version: 1, data: [ { refId: 'A', diff --git a/public/app/features/alerting/unified/utils/rule-form.test.ts b/public/app/features/alerting/unified/utils/rule-form.test.ts index 80442e87984..e8939c0c336 100644 --- a/public/app/features/alerting/unified/utils/rule-form.test.ts +++ b/public/app/features/alerting/unified/utils/rule-form.test.ts @@ -106,6 +106,7 @@ describe('getContactPointsFromDTO', () => { it('should return undefined if notification_settings is not defined', () => { const ga: GrafanaRuleDefinition = { uid: '123', + version: 1, title: 'myalert', namespace_uid: '123', rule_group: 'my-group', @@ -130,6 +131,7 @@ describe('getContactPointsFromDTO', () => { it('should return routingSettings with correct props if notification_settings is defined', () => { const ga: GrafanaRuleDefinition = { uid: '123', + version: 1, title: 'myalert', namespace_uid: '123', rule_group: 'my-group', diff --git a/public/app/features/alerting/unified/utils/rule-id.test.tsx b/public/app/features/alerting/unified/utils/rule-id.test.tsx index 41a343bba0a..e2652c11885 100644 --- a/public/app/features/alerting/unified/utils/rule-id.test.tsx +++ b/public/app/features/alerting/unified/utils/rule-id.test.tsx @@ -81,6 +81,7 @@ describe('hashRulerRule', () => { data: [], no_data_state: GrafanaAlertStateDecision.NoData, exec_err_state: GrafanaAlertStateDecision.Alerting, + version: 1, }; const grafanaRule: RulerGrafanaRuleDTO = { grafana_alert: grafanaAlertDefinition, diff --git a/public/app/features/browse-dashboards/fixtures/alertRules.fixture.ts b/public/app/features/browse-dashboards/fixtures/alertRules.fixture.ts index e1eec38e844..6cddb53dafa 100644 --- a/public/app/features/browse-dashboards/fixtures/alertRules.fixture.ts +++ b/public/app/features/browse-dashboards/fixtures/alertRules.fixture.ts @@ -22,6 +22,7 @@ export function getRulerRulesResponse(folderName: string, folderUid: string, see expr: '', for: '5m', grafana_alert: { + version: 2, id: '49', title: random.sentence({ words: 3 }), condition: 'B', diff --git a/public/app/features/dashboard-scene/settings/version-history/DiffTitle.tsx b/public/app/features/dashboard-scene/settings/version-history/DiffTitle.tsx index 19d7b9ffd3e..fb533d3a59b 100644 --- a/public/app/features/dashboard-scene/settings/version-history/DiffTitle.tsx +++ b/public/app/features/dashboard-scene/settings/version-history/DiffTitle.tsx @@ -26,7 +26,7 @@ export const DiffTitle = ({ diff, title }: DiffTitleProps) => { return diff ? ( <> {' '} - {title} {getDiffText(diff, diff.path.length > 1)}{' '} + {title} {getDiffText(diff, diff.path?.length > 1)}{' '} ) : ( @@ -45,10 +45,10 @@ const getDiffTitleStyles = (theme: GrafanaTheme2) => ({ color: theme.colors.success.main, }), replace: css({ - color: theme.colors.success.main, + color: theme.colors.warning.main, }), move: css({ - color: theme.colors.success.main, + color: theme.colors.warning.main, }), copy: css({ color: theme.colors.success.main, @@ -60,7 +60,7 @@ const getDiffTitleStyles = (theme: GrafanaTheme2) => ({ color: theme.colors.success.main, }), remove: css({ - color: theme.colors.success.main, + color: theme.colors.error.main, }), withoutDiff: css({ marginBottom: theme.spacing(1), diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index 302c6a4d0ba..85f06bf9722 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -242,6 +242,11 @@ export interface GrafanaEditorSettings { simplified_query_and_expressions_section: boolean; simplified_notifications_section: boolean; } + +export interface UpdatedBy { + uid: string; + name: string; +} export interface PostableGrafanaRuleDefinition { uid?: string; title: string; @@ -258,6 +263,7 @@ export interface PostableGrafanaRuleDefinition { metric: string; from: string; }; + intervalSeconds?: number; } export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition { id?: string; @@ -265,11 +271,11 @@ export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition { namespace_uid: string; rule_group: string; provenance?: string; - updated_by?: { - uid: string; - name?: string; - }; + // TODO: For updated_by, updated, and version, fix types so these aren't optional, and + // are not conflated with test fixtures updated?: string; + updated_by?: UpdatedBy | null; + version?: number; } export interface RulerGrafanaRuleDTO { @@ -279,6 +285,9 @@ export interface RulerGrafanaRuleDTO { labels: Labels; } +export type TopLevelGrafanaRuleDTOField = keyof Omit; +export type GrafanaAlertRuleDTOField = keyof GrafanaRuleDefinition; + export type PostableRuleGrafanaRuleDTO = RulerGrafanaRuleDTO; export type RulerCloudRuleDTO = RulerAlertingRuleDTO | RulerRecordingRuleDTO; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index f0a74858ebe..10b77debdf9 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -205,6 +205,41 @@ "recording": "Add labels to your rule." } }, + "alertVersionHistory": { + "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", + "compareVersions": "Compare versions", + "comparing-versions": "Comparing versions", + "condition": "Alert condition", + "contactPointRouting": "Contact point routing", + "description": "Each time you edit the alert rule, a new version is created. Select two versions below and compare their differences.", + "errorloading": "Failed to load alert rule versions", + "execErrorState": "Alert state when execution error", + "intervalSeconds": "Evaluation interval", + "labels": "Labels", + "latest": "Latest", + "name": "Name", + "namespace_uid": "Folder UID", + "noDataState": "Alert state when no data", + "noVersionsFound": "No versions found for this rule", + "paused": "Paused state", + "pendingPeriod": "Pending period", + "provisioning": "Provisioning", + "provisioning-change-description": "Version update was made via provisioning", + "queryAndAlertCondition": "Query and alert condition", + "reset": "Reset to version {{version}}", + "restore": "Restore", + "restore-modal": { + "body": "Are you sure you want to restore the alert rule definition to this version? All unsaved changes will be lost.", + "summary": "Summary of changes to be applied:", + "title": "Restore 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}}" + }, "annotations": { "description": "Add more context to your alert notifications.", "title": "Configure notification message" @@ -786,6 +821,25 @@ "placeholder": "Search all" } }, + "core": { + "versionHistory": { + "comparison": { + "header": { + "hide-json-diff": "Hide JSON diff ", + "show-json-diff": "Show JSON diff ", + "text": "Version {{version}} updated by {{createdBy}} ({{ageString}}) {{message}}" + }, + "select": "Select two versions to start comparing" + }, + "no-properties-changed": "No relevant properties changed", + "table": { + "updated": "Date", + "updatedBy": "Updated By", + "version": "Version" + }, + "view-json-diff": "View JSON diff to see all changes" + } + }, "correlations": { "add-new": "Add new", "alert": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 3002080e1b3..f39af58738b 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -205,6 +205,41 @@ "recording": "Åđđ ľäþęľş ŧő yőūř řūľę." } }, + "alertVersionHistory": { + "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ş", + "compareVersions": "Cőmpäřę vęřşįőʼnş", + "comparing-versions": "Cőmpäřįʼnģ vęřşįőʼnş", + "condition": "Åľęřŧ čőʼnđįŧįőʼn", + "contactPointRouting": "Cőʼnŧäčŧ pőįʼnŧ řőūŧįʼnģ", + "description": "Ēäčĥ ŧįmę yőū ęđįŧ ŧĥę äľęřŧ řūľę, ä ʼnęŵ vęřşįőʼn įş čřęäŧęđ. Ŝęľęčŧ ŧŵő vęřşįőʼnş þęľőŵ äʼnđ čőmpäřę ŧĥęįř đįƒƒęřęʼnčęş.", + "errorloading": "Fäįľęđ ŧő ľőäđ äľęřŧ řūľę vęřşįőʼnş", + "execErrorState": "Åľęřŧ şŧäŧę ŵĥęʼn ęχęčūŧįőʼn ęřřőř", + "intervalSeconds": "Ēväľūäŧįőʼn įʼnŧęřväľ", + "labels": "Ŀäþęľş", + "latest": "Ŀäŧęşŧ", + "name": "Ńämę", + "namespace_uid": "Főľđęř ŮĨĐ", + "noDataState": "Åľęřŧ şŧäŧę ŵĥęʼn ʼnő đäŧä", + "noVersionsFound": "Ńő vęřşįőʼnş ƒőūʼnđ ƒőř ŧĥįş řūľę", + "paused": "Päūşęđ şŧäŧę", + "pendingPeriod": "Pęʼnđįʼnģ pęřįőđ", + "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-modal": { + "body": "Åřę yőū şūřę yőū ŵäʼnŧ ŧő řęşŧőřę ŧĥę äľęřŧ řūľę đęƒįʼnįŧįőʼn ŧő ŧĥįş vęřşįőʼn? Åľľ ūʼnşävęđ čĥäʼnģęş ŵįľľ þę ľőşŧ.", + "summary": "Ŝūmmäřy őƒ čĥäʼnģęş ŧő þę äppľįęđ:", + "title": "Ŗęşŧőřę Vęřşįőʼn" + }, + "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}}" + }, "annotations": { "description": "Åđđ mőřę čőʼnŧęχŧ ŧő yőūř äľęřŧ ʼnőŧįƒįčäŧįőʼnş.", "title": "Cőʼnƒįģūřę ʼnőŧįƒįčäŧįőʼn męşşäģę" @@ -786,6 +821,25 @@ "placeholder": "Ŝęäřčĥ äľľ" } }, + "core": { + "versionHistory": { + "comparison": { + "header": { + "hide-json-diff": "Ħįđę ĴŜØŃ đįƒƒ ", + "show-json-diff": "Ŝĥőŵ ĴŜØŃ đįƒƒ ", + "text": "Vęřşįőʼn {{version}} ūpđäŧęđ þy {{createdBy}} ({{ageString}}) {{message}}" + }, + "select": "Ŝęľęčŧ ŧŵő vęřşįőʼnş ŧő şŧäřŧ čőmpäřįʼnģ" + }, + "no-properties-changed": "Ńő řęľęväʼnŧ přőpęřŧįęş čĥäʼnģęđ", + "table": { + "updated": "Đäŧę", + "updatedBy": "Ůpđäŧęđ ßy", + "version": "Vęřşįőʼn" + }, + "view-json-diff": "Vįęŵ ĴŜØŃ đįƒƒ ŧő şęę äľľ čĥäʼnģęş" + } + }, "correlations": { "add-new": "Åđđ ʼnęŵ", "alert": {