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 <tom.ratcliffe@grafana.com>

* 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 <tom.ratcliffe@grafana.com>
This commit is contained in:
Sonia Aguilar
2025-02-17 13:25:32 +01:00
committed by GitHub
co-authored by Tom Ratcliffe
parent c9250c9135
commit 2014d27def
29 changed files with 1089 additions and 54 deletions
@@ -258,4 +258,5 @@ export interface FeatureToggles {
grafanaconThemes?: boolean;
pluginsCDNSyncLoader?: boolean;
alertingJiraIntegration?: boolean;
alertingRuleVersionHistoryRestore?: boolean;
}
+9
View File
@@ -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,
},
}
)
+1
View File
@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
239 grafanaconThemes experimental @grafana/grafana-frontend-platform false true false
240 pluginsCDNSyncLoader experimental @grafana/plugins-platform-backend false false false
241 alertingJiraIntegration experimental @grafana/alerting-squad false false true
242 alertingRuleVersionHistoryRestore experimental @grafana/alerting-squad false false true
+4
View File
@@ -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"
)
+18
View File
@@ -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",
@@ -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<typeof jsonDiff>[0];
type DiffViewProps<T extends DiffArgument> = {
/** 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 (
<Trans i18nKey="core.versionHistory.comparison.header.text">
Version {{ version }} updated by {{ createdBy }} ({{ ageString }}) {{ message }}
</Trans>
);
};
export const VersionHistoryComparison = <T extends DiffArgument>({
oldSummary,
newSummary,
oldVersion,
newVersion,
preprocessVersion = identity,
}: DiffViewProps<T>) => {
const diff = jsonDiff(preprocessVersion(oldVersion), preprocessVersion(newVersion));
const noHumanReadableDiffs = Object.entries(diff).length === 0;
const [showJsonDiff, setShowJsonDiff] = useState(noHumanReadableDiffs);
return (
<Stack gap={2} direction="column">
<Box>
<Text variant="h5" element="h4">
<VersionChangeSummary info={oldSummary} />
<Icon name="arrow-right" />
<VersionChangeSummary info={newSummary} />
</Text>
</Box>
<Box>
{noHumanReadableDiffs && (
<EmptyState
message={t('core.versionHistory.no-properties-changed', 'No relevant properties changed')}
variant="not-found"
hideImage
>
<Trans i18nKey="core.versionHistory.view-json-diff">View JSON diff to see all changes</Trans>
</EmptyState>
)}
{Object.entries(diff).map(([key, diffs]) => (
<DiffGroup diffs={diffs} key={key} title={key} />
))}
<Divider />
</Box>
<Box>
{showJsonDiff && (
<Button variant="secondary" onClick={() => setShowJsonDiff(false)}>
<Trans i18nKey="core.versionHistory.comparison.header.hide-json-diff">Hide JSON diff </Trans>
</Button>
)}
{!showJsonDiff && (
<Button variant="secondary" onClick={() => setShowJsonDiff(true)}>
<Trans i18nKey="core.versionHistory.comparison.header.show-json-diff">Show JSON diff </Trans>
</Button>
)}
</Box>
{showJsonDiff && (
<DiffViewer oldValue={JSON.stringify(oldVersion, null, 2)} newValue={JSON.stringify(newVersion, null, 2)} />
)}
</Stack>
);
};
@@ -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';
@@ -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<RulerGrafanaRuleDTO, { uid: string }>({
// 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<RulerGrafanaRuleDTO[], { uid: string }>({
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<string, ExportRulesParams>({
query: ({ format, folderUid, group, ruleUid }) => ({
@@ -120,6 +120,7 @@ export const alertingApi = createApi({
'GrafanaLabels',
'CombinedAlertRule',
'GrafanaRulerRule',
'GrafanaRulerRuleVersion',
'GrafanaSlo',
'RuleGroup',
'RuleNamespace',
@@ -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(
<AlertRuleProvider identifier={identifier} rule={rule}>
<RuleViewer />
</AlertRuleProvider>,
@@ -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', () => ({
@@ -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) && <History rule={rule.rulerRule} />}
{activeTab === ActiveTab.Routing && <Routing />}
{activeTab === ActiveTab.Details && <Details rule={rule} />}
{activeTab === ActiveTab.VersionHistory && isGrafanaRulerRule(rule.rulerRule) && (
<AlertVersionHistory ruleUid={rule.rulerRule.grafana_alert.uid} />
)}
</TabContent>
</Stack>
{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,
@@ -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<keyof GrafanaRuleDefinition> = [
'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<RulerGrafanaRuleDTO<GrafanaRuleDefinition>>();
const [newVersion, setNewVersion] = useState<RulerGrafanaRuleDTO<GrafanaRuleDefinition>>();
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<string>());
const canCompare = useMemo(() => checkedVersions.size > 1, [checkedVersions]);
if (error) {
return (
<Alert title={t('alerting.alertVersionHistory.errorloading', 'Failed to load alert rule versions')}>
{stringifyErrorLike(error)}
</Alert>
);
}
if (isLoading) {
return <LoadingPlaceholder text={t('alerting.common.loading', 'Loading...')} />;
}
if (!ruleVersions.length) {
// We don't expect this to happen - all alert rules _should_ have at least one version
logInfo(LogMessages.noAlertRuleVersionsFound, { ruleUid });
return (
<EmptyState
variant="not-found"
message={t('alerting.alertVersionHistory.noVersionsFound', 'No versions found for this rule')}
/>
);
}
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 (
<Stack direction="column" gap={2}>
<Text variant="body">
<Trans i18nKey="alerting.alertVersionHistory.description">
Each time you edit the alert rule, a new version is created. Select two versions below and compare their
differences.
</Trans>
</Text>
<Stack>
<Tooltip
content={t('core.versionHistory.comparison.select', 'Select two versions to start comparing')}
placement="bottom"
>
<Button type="button" disabled={!canCompare} onClick={compareVersions} icon="code-branch">
<Trans i18nKey="alerting.alertVersionHistory.compareVersions">Compare versions</Trans>
</Button>
</Tooltip>
</Stack>
{showDrawer && oldVersion && newVersion && (
<Drawer
onClose={() => setShowDrawer(false)}
title={t('alerting.alertVersionHistory.comparing-versions', 'Comparing versions')}
>
<VersionHistoryComparison
oldSummary={parseVersionInfoToSummary(oldVersion)}
oldVersion={oldVersion}
newSummary={parseVersionInfoToSummary(newVersion)}
newVersion={newVersion}
preprocessVersion={preprocessRuleForDiffDisplay}
/>
{config.featureToggles.alertingRuleVersionHistoryRestore && (
<Box paddingTop={2}>
<Stack justifyContent="flex-end">
<Button variant="destructive" onClick={() => {}}>
<Trans i18nKey="alerting.alertVersionHistory.reset">
Reset to version {{ version: oldVersion.grafana_alert.version }}
</Trans>
</Button>
</Stack>
</Box>
)}
</Drawer>
)}
<VersionHistoryTable
onVersionsChecked={handleCheckedVersionChange}
ruleVersions={ruleVersions}
disableSelection={canCompare}
checkedVersions={checkedVersions}
/>
</Stack>
);
}
/**
* Turns a version of a Grafana rule definition into data structure
* used to display the version summary when comparing versions
*/
function parseVersionInfoToSummary(version: RulerGrafanaRuleDTO<GrafanaRuleDefinition>): RevisionModel {
const unknown = t('alerting.alertVersionHistory.unknown', 'Unknown');
const SPECIAL_UID_MAP = getSpecialUidsDisplayMap();
const createdBy = (() => {
const updatedBy = version?.grafana_alert.updated_by;
const uid = updatedBy?.uid;
const name = updatedBy?.name;
if (!updatedBy) {
return unknown;
}
if (uid && SPECIAL_UID_MAP[uid]) {
return SPECIAL_UID_MAP[uid].name;
}
if (name) {
return name;
}
return uid ? t('alerting.alertVersionHistory.user-id', 'User ID {{uid}}', { uid }) : unknown;
})();
return {
created: version.grafana_alert.updated || unknown,
createdBy,
version: version.grafana_alert.version || unknown,
};
}
@@ -18,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) => {
<DetailText
id="last-updated-by"
label={t('alerting.alert.last-updated-by', 'Last updated by')}
value={lastUpdatedBy}
value={<UpdatedByUser user={rule.rulerRule.grafana_alert.updated_by} />}
/>
{updated && (
<DetailText
@@ -0,0 +1,63 @@
import { css } from '@emotion/css';
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';
export const UpdatedByUser = ({ user }: { user: UpdatedBy | null | undefined }) => {
const unknown = t('alerting.alertVersionHistory.unknown', 'Unknown');
const SPECIAL_UID_MAP = getSpecialUidsDisplayMap();
const styles = useStyles2(getStyles);
const unknownCase = (
<Tooltip
content={t(
'alerting.alertVersionHistory.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'
)}
>
<span>
<span className={styles.underline}>{unknown} </span>
<Icon name="question-circle" />
</span>
</Tooltip>
);
if (!user) {
return unknownCase;
}
const specialCase = SPECIAL_UID_MAP[user.uid];
if (specialCase || !user) {
return (
<Tooltip content={specialCase.tooltipContent}>
<span>
<Badge
className={styles.badge}
text={specialCase.name}
color={specialCase.badgeColor}
icon={specialCase.icon}
/>
</span>
</Tooltip>
);
}
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',
}),
};
};
@@ -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<RulerGrafanaRuleDTO<GrafanaRuleDefinition>>;
disableSelection: boolean;
checkedVersions: Set<string>;
}) {
//----> restore code : no need to review as it's behind a feature flag
const [confirmRestore, setConfirmRestore] = useState(false);
const [restoreDiff, setRestoreDiff] = useState<Diffs | undefined>();
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<Column<(typeof ruleVersions)[0]>> = [
{
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 (
<Stack direction="row">
<Checkbox
label={id}
checked={thisValue}
disabled={disableSelection && !thisValue}
onChange={() => {
onVersionsChecked(id);
}}
/>
</Stack>
);
},
},
{
id: 'createdBy',
header: t('core.versionHistory.table.updatedBy', 'Updated By'),
disableGrow: true,
cell: ({ row }) => {
return <UpdatedByUser user={row.original.grafana_alert.updated_by} />;
},
},
{
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 (
<Stack alignItems="baseline" gap={0.5}>
<Text color="success" variant="bodySmall">
{added}
</Text>
<Text color="error" variant="bodySmall">
{removed}
</Text>
</Stack>
);
},
},
{
id: 'actions',
disableGrow: true,
cell: ({ row }) => {
const isFirstItem = row.index === 0;
return (
<Stack direction="row" alignItems="center" justifyContent="flex-end">
{isFirstItem ? (
<Badge text={t('alerting.alertVersionHistory.latest', 'Latest')} color="blue" />
) : config.featureToggles.alertingRuleVersionHistoryRestore ? (
<Button
variant="secondary"
size="sm"
icon="history"
onClick={() => {
showConfirmation(row.values.id);
}}
>
<Trans i18nKey="alerting.alertVersionHistory.restore">Restore</Trans>
</Button>
) : null}
</Stack>
);
},
},
];
return (
<>
<InteractiveTable
pageSize={VERSIONS_PAGE_SIZE}
columns={columns}
data={ruleVersions}
getRowId={(row) => `${row.grafana_alert.version}`}
/>
{/* ---------------------> restore code: no need to review for this pr as it's behind a feature flag */}
<ConfirmModal
isOpen={confirmRestore}
title={t('alerting.alertVersionHistory.restore-modal.title', 'Restore Version')}
body={
<Stack direction="column" gap={2}>
<Trans i18nKey="alerting.alertVersionHistory.restore-modal.body">
Are you sure you want to restore the alert rule definition to this version? All unsaved changes will be
lost.
</Trans>
<Text variant="h6">
<Trans i18nKey="alerting.alertVersionHistory.restore-modal.summary">
Summary of changes to be applied:
</Trans>
</Text>
<div>
{restoreDiff && (
<>
{Object.entries(restoreDiff).map(([key, diffs]) => (
<DiffGroup diffs={diffs} key={key} title={key} />
))}
</>
)}
</div>
</Stack>
}
confirmText={'Yes, restore configuration'}
onConfirm={() => {
hideConfirmation();
}}
onDismiss={() => hideConfirmation()}
/>
{/* ------------------------------------> END OF RESTORING CODE */}
</>
);
}
@@ -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<string, SpecialUidsDisplayMapEntry> = () => {
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<GrafanaRuleDefinition>) {
const { grafana_alert, ...rest } = rulerRule;
/** Translations for top level properties of alert, other than `grafana_alert` */
const translationMap: Partial<Record<TopLevelGrafanaRuleDTOField, string>> = {
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<Record<GrafanaAlertRuleDTOField, string>> = {
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<RulerGrafanaRuleDTO, 'grafana_alert'>;
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,
};
}
@@ -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 };
@@ -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)));
},
};
}
@@ -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<GrafanaRuleDefinition>) => {
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<GrafanaRuleDefinition>) => {
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<GrafanaRuleDefinition>) => {
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<GrafanaRuleDefinition>) => {
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<GrafanaRuleDefinition>) => {
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<GrafanaRuleDefinition>) => {
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;
@@ -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);
});
});
@@ -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<T extends Object>(
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,
};
}
@@ -93,6 +93,7 @@ const grafanaAlert = {
no_data_state: GrafanaAlertStateDecision.NoData,
title: 'Test alert',
uid: 'asdf23',
version: 1,
data: [
{
refId: 'A',
@@ -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',
@@ -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,
@@ -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',
@@ -26,7 +26,7 @@ export const DiffTitle = ({ diff, title }: DiffTitleProps) => {
return diff ? (
<>
<Icon type="mono" name="circle" className={styles[diff.op]} size="xs" />{' '}
<span className={styles.embolden}>{title}</span> <span>{getDiffText(diff, diff.path.length > 1)}</span>{' '}
<span className={styles.embolden}>{title}</span> <span>{getDiffText(diff, diff.path?.length > 1)}</span>{' '}
<DiffValues diff={diff} />
</>
) : (
@@ -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),
+13 -4
View File
@@ -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<T = GrafanaRuleDefinition> {
@@ -279,6 +285,9 @@ export interface RulerGrafanaRuleDTO<T = GrafanaRuleDefinition> {
labels: Labels;
}
export type TopLevelGrafanaRuleDTOField = keyof Omit<RulerGrafanaRuleDTO, 'grafana_alert'>;
export type GrafanaAlertRuleDTOField = keyof GrafanaRuleDefinition;
export type PostableRuleGrafanaRuleDTO = RulerGrafanaRuleDTO<PostableGrafanaRuleDefinition>;
export type RulerCloudRuleDTO = RulerAlertingRuleDTO | RulerRecordingRuleDTO;
+54
View File
@@ -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": {
+54
View File
@@ -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": {