Skip to content

Commit 6e05c19

Browse files
committed
add UI for rule changes history
1 parent 96a8c27 commit 6e05c19

20 files changed

Lines changed: 1075 additions & 0 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React from 'react';
9+
import { EuiFlexGroup, EuiSpacer, EuiTitle } from '@elastic/eui';
10+
import { SplitAccordion } from '../../../../common/components/split_accordion';
11+
import { convertFieldToDisplayName } from '../../../rule_management/components/rule_details/helpers';
12+
import { DiffView } from '../../../rule_management/components/rule_details/json_diff/diff_view';
13+
import * as i18n from './translations';
14+
15+
interface ChangeDetailsTabProps {
16+
changedFields: string[];
17+
oldValues: Record<string, unknown>;
18+
ruleSnapshot: Record<string, unknown>;
19+
}
20+
21+
const formatValueForDiff = (value: unknown): string => {
22+
if (value === null || value === undefined) return '';
23+
if (typeof value === 'object') return JSON.stringify(value, null, 2);
24+
return String(value);
25+
};
26+
27+
export function ChangeDetailsTab({
28+
changedFields,
29+
oldValues,
30+
ruleSnapshot,
31+
}: ChangeDetailsTabProps): JSX.Element {
32+
if (changedFields.length === 0) {
33+
return <p>{i18n.NO_VISIBLE_CHANGES}</p>;
34+
}
35+
36+
return (
37+
<>
38+
{changedFields.map((fieldName) => (
39+
<React.Fragment key={fieldName}>
40+
<SplitAccordion
41+
header={
42+
<EuiTitle size="xs">
43+
<h5>{convertFieldToDisplayName(fieldName)}</h5>
44+
</EuiTitle>
45+
}
46+
initialIsOpen
47+
>
48+
<EuiFlexGroup justifyContent="spaceBetween">
49+
<DiffView
50+
viewType="unified"
51+
oldSource={formatValueForDiff(oldValues[fieldName])}
52+
newSource={formatValueForDiff(ruleSnapshot[fieldName])}
53+
/>
54+
</EuiFlexGroup>
55+
</SplitAccordion>
56+
<EuiSpacer size="l" />
57+
</React.Fragment>
58+
))}
59+
</>
60+
);
61+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React, { useMemo, useState } from 'react';
9+
import {
10+
EuiFlyout,
11+
EuiFlyoutBody,
12+
EuiFlyoutFooter,
13+
EuiFlyoutHeader,
14+
EuiSpacer,
15+
EuiTab,
16+
EuiTabs,
17+
useGeneratedHtmlId,
18+
} from '@elastic/eui';
19+
import type { RuleHistoryItem } from '../../../../../common/api/detection_engine/rule_management';
20+
import { ChangeDetailsTab } from './change_details_tab';
21+
import { ChangeHistoryFlyoutHeader } from './change_history_flyout_header';
22+
import { IGNORED_DIFF_FIELDS } from './constants';
23+
import { extractChangedFieldNames } from './extract_changed_field_names';
24+
import { OverviewTab } from './overview_tab';
25+
import { ChangeHistoryFlyoutActions } from './change_history_flyout_actions';
26+
import * as i18n from './translations';
27+
28+
interface ChangeHistoryFlyoutProps {
29+
item: RuleHistoryItem;
30+
onClose: () => void;
31+
}
32+
33+
type FlyoutTabId = 'changes' | 'overview';
34+
35+
export function ChangeHistoryFlyout({ item, onClose }: ChangeHistoryFlyoutProps): JSX.Element {
36+
const titleId = useGeneratedHtmlId();
37+
const [selectedTab, setSelectedTab] = useState<FlyoutTabId>('changes');
38+
const changedFields = useMemo(() => extractChangedFieldNames(item, IGNORED_DIFF_FIELDS), [item]);
39+
const ruleSnapshot = item.rule as Record<string, unknown>;
40+
const oldValues = (item.old_values ?? {}) as Record<string, unknown>;
41+
42+
return (
43+
<EuiFlyout
44+
ownFocus
45+
onClose={onClose}
46+
size="m"
47+
aria-labelledby={titleId}
48+
data-test-subj="ruleChangeHistoryFlyout"
49+
>
50+
<EuiFlyoutHeader>
51+
<ChangeHistoryFlyoutHeader item={item} titleId={titleId} />
52+
<EuiSpacer size="m" />
53+
<EuiTabs>
54+
{TABS.map((tab) => (
55+
<EuiTab
56+
key={tab.id}
57+
isSelected={tab.id === selectedTab}
58+
onClick={() => setSelectedTab(tab.id)}
59+
data-test-subj={`ruleChangeHistoryFlyoutTab-${tab.id}`}
60+
>
61+
{tab.title}
62+
</EuiTab>
63+
))}
64+
</EuiTabs>
65+
</EuiFlyoutHeader>
66+
<EuiFlyoutBody>
67+
{selectedTab === 'changes' ? (
68+
<ChangeDetailsTab
69+
changedFields={changedFields}
70+
oldValues={oldValues}
71+
ruleSnapshot={ruleSnapshot}
72+
/>
73+
) : (
74+
<OverviewTab rule={item.rule} />
75+
)}
76+
</EuiFlyoutBody>
77+
<EuiFlyoutFooter>
78+
<ChangeHistoryFlyoutActions onClose={onClose} />
79+
</EuiFlyoutFooter>
80+
</EuiFlyout>
81+
);
82+
}
83+
84+
const TABS = [
85+
{ id: 'changes', title: i18n.CHANGE_DETAILS_TAB_TITLE },
86+
{ id: 'overview', title: i18n.OVERVIEW_AT_SAVE_TAB_TITLE },
87+
] as const;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React from 'react';
9+
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
10+
import * as i18n from './translations';
11+
12+
interface ChangeHistoryFlyoutActionsProps {
13+
onClose: () => void;
14+
}
15+
16+
export function ChangeHistoryFlyoutActions({
17+
onClose,
18+
}: ChangeHistoryFlyoutActionsProps): JSX.Element {
19+
return (
20+
<EuiFlexGroup justifyContent="flexEnd">
21+
<EuiFlexItem grow={false}>
22+
<EuiButtonEmpty onClick={onClose} data-test-subj="ruleChangeHistoryFlyoutCloseButton">
23+
{i18n.CLOSE_BUTTON_LABEL}
24+
</EuiButtonEmpty>
25+
</EuiFlexItem>
26+
</EuiFlexGroup>
27+
);
28+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React, { useMemo } from 'react';
9+
import { isNumber } from 'lodash';
10+
import { EuiFlexGroup, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
11+
import type { RuleHistoryItem } from '../../../../../common/api/detection_engine/rule_management';
12+
import { describeAction } from './describe_action';
13+
import * as i18n from './translations';
14+
import { extractChangedFieldNames } from './extract_changed_field_names';
15+
import { IGNORED_DIFF_FIELDS } from './constants';
16+
17+
interface ChangeHistoryFlyoutHeaderProps {
18+
item: RuleHistoryItem;
19+
titleId: string;
20+
}
21+
22+
export function ChangeHistoryFlyoutHeader({
23+
item,
24+
titleId,
25+
}: ChangeHistoryFlyoutHeaderProps): JSX.Element {
26+
const userName = item.user?.name ?? i18n.SYSTEM_USER_LABEL;
27+
const changedFields = useMemo(() => extractChangedFieldNames(item, IGNORED_DIFF_FIELDS), [item]);
28+
29+
return (
30+
<>
31+
<EuiTitle size="m">
32+
<h2 id={titleId}>{i18n.CHANGE_DETAILS_FLYOUT_TITLE}</h2>
33+
</EuiTitle>
34+
<EuiSpacer size="s" />
35+
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false} wrap>
36+
<EuiText size="s">
37+
<i18n.COMPARED_REVISIONS
38+
revisionBefore={item.old_values?.revision as number | undefined}
39+
revisionAfter={item.rule.revision}
40+
/>
41+
</EuiText>
42+
<EuiText size="s">
43+
<i18n.UPDATED_BY
44+
action={describeAction(item.action)}
45+
username={userName}
46+
timestamp={item.timestamp}
47+
/>
48+
</EuiText>
49+
</EuiFlexGroup>
50+
<EuiSpacer size="s" />
51+
{changedFields.length > 0 && (
52+
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false} wrap>
53+
<EuiText size="s">
54+
<i18n.FIELD_CHANGES fields={changedFields} />
55+
</EuiText>
56+
</EuiFlexGroup>
57+
)}
58+
</>
59+
);
60+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
export { DATE_DISPLAY_FORMAT, IGNORED_DIFF_FIELDS } from '../change_history_table/constants';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import * as i18n from './translations';
9+
10+
/**
11+
* Translate a raw action identifier (e.g. `rule_create`) into a human-readable
12+
* sentence fragment. Unknown actions are surfaced as the verbatim action key
13+
* with underscores stripped, so we never silently drop information when the
14+
* alerting framework starts emitting a new action type.
15+
*/
16+
export function describeAction(action: string): string {
17+
return KNOWN_ACTION_LABELS[action] ?? action.replaceAll('_', ' ');
18+
}
19+
20+
const KNOWN_ACTION_LABELS: Record<string, string> = {
21+
rule_create: i18n.ACTION_RULE_CREATE,
22+
rule_update: i18n.ACTION_RULE_UPDATE,
23+
rule_enable: i18n.ACTION_RULE_ENABLE,
24+
rule_disable: i18n.ACTION_RULE_DISABLE,
25+
rule_snooze: i18n.ACTION_RULE_SNOOZE,
26+
rule_unsnooze: i18n.ACTION_RULE_UNSNOOZE,
27+
rule_bulk_edit: i18n.ACTION_RULE_BULK_EDIT,
28+
rule_api_key_update: i18n.ACTION_RULE_API_KEY_UPDATE,
29+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import type { RuleHistoryItem } from '../../../../../common/api/detection_engine/rule_management';
9+
10+
/**
11+
* Extract the list of changed-field names from a history item, stripping out
12+
* fields that should never be shown to the user. The source is the
13+
* `old_values` RFC 7396 merge patch — its top-level keys are exactly the
14+
* fields that differ between this revision and the previous one.
15+
*/
16+
export const extractChangedFieldNames = (
17+
item: Pick<RuleHistoryItem, 'old_values'>,
18+
ignored: ReadonlySet<string>
19+
): string[] => {
20+
if (!item.old_values) {
21+
return [];
22+
}
23+
24+
return Object.keys(item.old_values).filter((field) => !ignored.has(field));
25+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
export { ChangeHistoryFlyout } from './change_history_flyout';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React from 'react';
9+
import { EuiAccordion, EuiFlexGroup, EuiHorizontalRule, EuiSpacer, EuiTitle } from '@elastic/eui';
10+
import type { RuleHistoryItem } from '../../../../../common/api/detection_engine/rule_management';
11+
import { DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS } from '../../../rule_management/components/rule_details/constants';
12+
import { RuleAboutSection } from '../../../rule_management/components/rule_details/rule_about_section';
13+
import { RuleDefinitionSection } from '../../../rule_management/components/rule_details/rule_definition_section';
14+
import * as i18n from './translations';
15+
16+
interface OverviewTabProps {
17+
rule: RuleHistoryItem['rule'];
18+
}
19+
20+
export function OverviewTab({ rule }: OverviewTabProps): JSX.Element {
21+
return (
22+
<>
23+
<EuiAccordion
24+
id="ruleChangeHistoryFlyoutAbout"
25+
buttonContent={
26+
<EuiTitle size="m">
27+
<h3>{i18n.OVERVIEW_ABOUT_TITLE}</h3>
28+
</EuiTitle>
29+
}
30+
initialIsOpen
31+
data-test-subj="ruleChangeHistoryFlyoutAboutSection"
32+
>
33+
<EuiFlexGroup justifyContent="spaceBetween">
34+
<RuleAboutSection rule={rule} hideName={false} hideDescription={false} />
35+
</EuiFlexGroup>
36+
</EuiAccordion>
37+
<EuiSpacer size="m" />
38+
<EuiHorizontalRule />
39+
<EuiSpacer size="l" />
40+
<EuiAccordion
41+
id="ruleChangeHistoryFlyoutDefinition"
42+
buttonContent={
43+
<EuiTitle size="m">
44+
<h3>{i18n.OVERVIEW_DEFINITION_TITLE}</h3>
45+
</EuiTitle>
46+
}
47+
initialIsOpen
48+
data-test-subj="ruleChangeHistoryFlyoutDefinitionSection"
49+
>
50+
<EuiSpacer size="m" />
51+
<EuiFlexGroup justifyContent="spaceBetween">
52+
<RuleDefinitionSection
53+
rule={rule}
54+
columnWidths={DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS}
55+
/>
56+
</EuiFlexGroup>
57+
</EuiAccordion>
58+
<EuiSpacer size="m" />
59+
</>
60+
);
61+
}

0 commit comments

Comments
 (0)