Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { EuiFlexGroup, EuiSpacer, EuiTitle } from '@elastic/eui';
import { SplitAccordion } from '../../../../common/components/split_accordion';
import { convertFieldToDisplayName } from '../../../rule_management/components/rule_details/helpers';
import { DiffView } from '../../../rule_management/components/rule_details/json_diff/diff_view';
import * as i18n from './translations';

interface ChangeDetailsTabProps {
changedFields: string[];
oldValues: Record<string, unknown>;
ruleSnapshot: Record<string, unknown>;
}

const formatValueForDiff = (value: unknown): string => {
if (value === null || value === undefined) return '';
if (typeof value === 'object') return JSON.stringify(value, null, 2);
return String(value);
};

export function ChangeDetailsTab({
changedFields,
oldValues,
ruleSnapshot,
}: ChangeDetailsTabProps): JSX.Element {
if (changedFields.length === 0) {
return <p>{i18n.NO_VISIBLE_CHANGES}</p>;
}

return (
<>
{changedFields.map((fieldName) => (
<React.Fragment key={fieldName}>
<SplitAccordion
header={
<EuiTitle size="xs">
<h5>{convertFieldToDisplayName(fieldName)}</h5>
</EuiTitle>
}
initialIsOpen
>
<EuiFlexGroup justifyContent="spaceBetween">
<DiffView
viewType="unified"
oldSource={formatValueForDiff(oldValues[fieldName])}
newSource={formatValueForDiff(ruleSnapshot[fieldName])}
/>
</EuiFlexGroup>
</SplitAccordion>
<EuiSpacer size="l" />
</React.Fragment>
))}
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React, { useMemo, useState } from 'react';
import {
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiSpacer,
EuiTab,
EuiTabs,
useGeneratedHtmlId,
} from '@elastic/eui';
import type { RuleHistoryItem } from '../../../../../common/api/detection_engine/rule_management';
import { extractChangedFieldNames } from '../../utils/extract_changed_field_names';
import { ChangeDetailsTab } from './change_details_tab';
import { ChangeHistoryFlyoutHeader } from './change_history_flyout_header';
import { OverviewTab } from './overview_tab';
import { ChangeHistoryFlyoutActions } from './change_history_flyout_actions';
import * as i18n from './translations';

interface ChangeHistoryFlyoutProps {
item: RuleHistoryItem;
onClose: () => void;
}

export function ChangeHistoryFlyout({ item, onClose }: ChangeHistoryFlyoutProps): JSX.Element {
const titleId = useGeneratedHtmlId();
const changedFields = useMemo(() => extractChangedFieldNames(item), [item]);
const [selectedTab, setSelectedTab] = useState(
changedFields.length > 0 ? TabId.Changes : TabId.Overview
);
const tabs = useMemo(
() => (changedFields.length > 0 ? TABS : TABS_WITHOUT_CHANGES),
[changedFields]
);
const ruleSnapshot = item.rule as Record<string, unknown>;
const oldValues = (item.old_values ?? {}) as Record<string, unknown>;

return (
<EuiFlyout
ownFocus
onClose={onClose}
size="m"
aria-labelledby={titleId}
data-test-subj="ruleChangeHistoryFlyout"
>
<EuiFlyoutHeader>
<ChangeHistoryFlyoutHeader item={item} titleId={titleId} changedFields={changedFields} />
<EuiSpacer size="m" />
<EuiTabs>
{tabs.map((tab) => (
<EuiTab
key={tab.id}
isSelected={tab.id === selectedTab}
onClick={() => setSelectedTab(tab.id)}
data-test-subj={`ruleChangeHistoryFlyoutTab-${tab.id}`}
>
{tab.title}
</EuiTab>
))}
</EuiTabs>
</EuiFlyoutHeader>
<EuiFlyoutBody>
{selectedTab === TabId.Changes ? (
<ChangeDetailsTab
changedFields={changedFields}
oldValues={oldValues}
ruleSnapshot={ruleSnapshot}
/>
) : (
<OverviewTab rule={item.rule} />
)}
</EuiFlyoutBody>
<EuiFlyoutFooter>
<ChangeHistoryFlyoutActions onClose={onClose} />
</EuiFlyoutFooter>
</EuiFlyout>
);
}

enum TabId {
Changes = 'changes',
Overview = 'overview',
}

const TABS = [
{ id: TabId.Changes, title: i18n.CHANGE_DETAILS_TAB_TITLE },
{ id: TabId.Overview, title: i18n.OVERVIEW_AT_SAVE_TAB_TITLE },
] as const;

const TABS_WITHOUT_CHANGES = [
{ id: TabId.Overview, title: i18n.OVERVIEW_AT_SAVE_TAB_TITLE },
] as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import * as i18n from './translations';

interface ChangeHistoryFlyoutActionsProps {
onClose: () => void;
}

export function ChangeHistoryFlyoutActions({
onClose,
}: ChangeHistoryFlyoutActionsProps): JSX.Element {
return (
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onClose} data-test-subj="ruleChangeHistoryFlyoutCloseButton">
{i18n.CLOSE_BUTTON_LABEL}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { EuiFlexGroup, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import type { RuleHistoryItem } from '../../../../../common/api/detection_engine/rule_management';
import { describeAction } from './describe_action';
import * as i18n from './translations';

interface ChangeHistoryFlyoutHeaderProps {
item: RuleHistoryItem;
titleId: string;
changedFields: string[];
}

export function ChangeHistoryFlyoutHeader({
item,
titleId,
changedFields,
}: ChangeHistoryFlyoutHeaderProps): JSX.Element {
const userName = item.user?.name ?? i18n.SYSTEM_USER_LABEL;
const prevRevision = item.old_values?.revision as number | undefined;

return (
<>
<EuiTitle size="m">
<h2 id={titleId}>{i18n.CHANGE_DETAILS_FLYOUT_TITLE}</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false} wrap>
<EuiText size="s">
{prevRevision !== undefined ? (
<i18n.COMPARED_REVISIONS
revisionBefore={prevRevision}
revisionAfter={item.rule.revision}
/>
) : (
<i18n.VIEW_REVISION revision={item.rule.revision} />
)}
</EuiText>
<EuiText size="s">
<i18n.UPDATED_BY
action={describeAction(item.action)}
username={userName}
timestamp={item.timestamp}
/>
</EuiText>
</EuiFlexGroup>
<EuiSpacer size="s" />
{changedFields.length > 0 && (
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false} wrap>
<EuiText size="s">
<i18n.FIELD_CHANGES fields={changedFields} />
</EuiText>
</EuiFlexGroup>
)}
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

export { DATE_DISPLAY_FORMAT } from '../change_history_table/constants';
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import * as i18n from './translations';

/**
* Translate a raw action identifier (e.g. `rule_create`) into a human-readable
* sentence fragment. Unknown actions are surfaced as the verbatim action key
* with underscores stripped, so we never silently drop information when the
* alerting framework starts emitting a new action type.
*/
export function describeAction(action: string): string {
return KNOWN_ACTION_LABELS[action] ?? action.replaceAll('_', ' ');
}

const KNOWN_ACTION_LABELS: Record<string, string> = {
rule_create: i18n.ACTION_RULE_CREATE,
rule_update: i18n.ACTION_RULE_UPDATE,
rule_enable: i18n.ACTION_RULE_ENABLE,
rule_disable: i18n.ACTION_RULE_DISABLE,
rule_snooze: i18n.ACTION_RULE_SNOOZE,
rule_unsnooze: i18n.ACTION_RULE_UNSNOOZE,
rule_bulk_edit: i18n.ACTION_RULE_BULK_EDIT,
rule_api_key_update: i18n.ACTION_RULE_API_KEY_UPDATE,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

export { ChangeHistoryFlyout } from './change_history_flyout';
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import {
EuiAccordion,
EuiFlexGroup,
EuiHorizontalRule,
EuiSpacer,
EuiTitle,
useGeneratedHtmlId,
} from '@elastic/eui';
import type { RuleHistoryItem } from '../../../../../common/api/detection_engine/rule_management';
import { DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS } from '../../../rule_management/components/rule_details/constants';
import { RuleAboutSection } from '../../../rule_management/components/rule_details/rule_about_section';
import { RuleDefinitionSection } from '../../../rule_management/components/rule_details/rule_definition_section';
import * as i18n from './translations';

interface OverviewTabProps {
rule: RuleHistoryItem['rule'];
}

export function OverviewTab({ rule }: OverviewTabProps): JSX.Element {
const aboutId = useGeneratedHtmlId();
const definitionId = useGeneratedHtmlId();

return (
<>
<EuiAccordion
id={aboutId}
buttonContent={
<EuiTitle size="m">
<h3>{i18n.OVERVIEW_ABOUT_TITLE}</h3>
</EuiTitle>
}
initialIsOpen
data-test-subj="ruleChangeHistoryFlyoutAboutSection"
>
<EuiFlexGroup justifyContent="spaceBetween">
<RuleAboutSection rule={rule} hideName={false} hideDescription={false} />
</EuiFlexGroup>
</EuiAccordion>
<EuiSpacer size="m" />
<EuiHorizontalRule />
<EuiSpacer size="l" />
<EuiAccordion
id={definitionId}
buttonContent={
<EuiTitle size="m">
<h3>{i18n.OVERVIEW_DEFINITION_TITLE}</h3>
</EuiTitle>
}
initialIsOpen
data-test-subj="ruleChangeHistoryFlyoutDefinitionSection"
>
<EuiSpacer size="m" />
<EuiFlexGroup justifyContent="spaceBetween">
<RuleDefinitionSection
rule={rule}
columnWidths={DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS}
/>
</EuiFlexGroup>
</EuiAccordion>
<EuiSpacer size="m" />
</>
);
}
Loading
Loading