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:
co-authored by
Tom Ratcliffe
parent
c9250c9135
commit
2014d27def
@@ -258,4 +258,5 @@ export interface FeatureToggles {
|
||||
grafanaconThemes?: boolean;
|
||||
pluginsCDNSyncLoader?: boolean;
|
||||
alertingJiraIntegration?: boolean;
|
||||
alertingRuleVersionHistoryRestore?: boolean;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
+190
@@ -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
|
||||
|
||||
+63
@@ -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',
|
||||
}),
|
||||
};
|
||||
};
|
||||
+185
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user