Skip to content

Commit

Permalink
[SIEM] Export timeline (elastic#58368)
Browse files Browse the repository at this point in the history
* update layout

* add utility bars

* add icon

* adding a route for exporting timeline

* organizing data

* fix types

* fix incorrect props for timeline table

* add export timeline to tables action

* fix types

* add client side unit test

* add server-side unit test

* fix title for delete timelines

* fix unit tests

* update snapshot

* fix dependency

* add table ref

* remove custom link

* remove custom links

* Update x-pack/legacy/plugins/siem/common/constants.ts

Co-Authored-By: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>

* remove type ExportTimelineIds

* reduce props

* Get notes and pinned events by timeline id

* combine notes and pinned events data

* fix unit test

* fix type error

* fix type error

* fix unit tests

* fix for review

* clean up generic downloader

* review with angela

* review utils

* fix for code review

* fix for review

* fix tests

* review

* fix title of delete modal

* remove an extra bracket

Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
  • Loading branch information
3 people committed Mar 20, 2020
1 parent 8f1e22f commit ab44099
Show file tree
Hide file tree
Showing 63 changed files with 2,425 additions and 1,881 deletions.
3 changes: 3 additions & 0 deletions x-pack/legacy/plugins/siem/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ export const DETECTION_ENGINE_TAGS_URL = `${DETECTION_ENGINE_URL}/tags`;
export const DETECTION_ENGINE_RULES_STATUS_URL = `${DETECTION_ENGINE_RULES_URL}/_find_statuses`;
export const DETECTION_ENGINE_PREPACKAGED_RULES_STATUS_URL = `${DETECTION_ENGINE_RULES_URL}/prepackaged/_status`;

export const TIMELINE_URL = '/api/timeline';
export const TIMELINE_EXPORT_URL = `${TIMELINE_URL}/_export`;

/**
* Default signals index key for kibana.dev.yml
*/
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@

import { shallow } from 'enzyme';
import React from 'react';
import { RuleDownloaderComponent } from './index';
import { GenericDownloaderComponent } from './index';

describe('RuleDownloader', () => {
describe('GenericDownloader', () => {
test('renders correctly against snapshot', () => {
const wrapper = shallow(
<RuleDownloaderComponent filename={'export_rules.ndjson'} onExportComplete={jest.fn()} />
<GenericDownloaderComponent
filename={'export_rules.ndjson'}
onExportSuccess={jest.fn()}
exportSelectedData={jest.fn()}
/>
);
expect(wrapper).toMatchSnapshot();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,28 @@
import React, { useEffect, useRef } from 'react';
import styled from 'styled-components';
import { isFunction } from 'lodash/fp';
import { exportRules } from '../../../../../containers/detection_engine/rules';
import { useStateToaster, errorToToaster } from '../../../../../components/toasters';
import * as i18n from './translations';

import { ExportDocumentsProps } from '../../containers/detection_engine/rules';
import { useStateToaster, errorToToaster } from '../toasters';

const InvisibleAnchor = styled.a`
display: none;
`;

export interface RuleDownloaderProps {
export type ExportSelectedData = ({
excludeExportDetails,
filename,
ids,
signal,
}: ExportDocumentsProps) => Promise<Blob>;

export interface GenericDownloaderProps {
filename: string;
ruleIds?: string[];
onExportComplete: (exportCount: number) => void;
ids?: string[];
exportSelectedData: ExportSelectedData;
onExportSuccess?: (exportCount: number) => void;
onExportFailure?: () => void;
}

/**
Expand All @@ -28,23 +38,26 @@ export interface RuleDownloaderProps {
* @param payload Rule[]
*
*/
export const RuleDownloaderComponent = ({

export const GenericDownloaderComponent = ({
exportSelectedData,
filename,
ruleIds,
onExportComplete,
}: RuleDownloaderProps) => {
ids,
onExportSuccess,
onExportFailure,
}: GenericDownloaderProps) => {
const anchorRef = useRef<HTMLAnchorElement>(null);
const [, dispatchToaster] = useStateToaster();

useEffect(() => {
let isSubscribed = true;
const abortCtrl = new AbortController();

async function exportData() {
if (anchorRef && anchorRef.current && ruleIds != null && ruleIds.length > 0) {
const exportData = async () => {
if (anchorRef && anchorRef.current && ids != null && ids.length > 0) {
try {
const exportResponse = await exportRules({
ruleIds,
const exportResponse = await exportSelectedData({
ids,
signal: abortCtrl.signal,
});

Expand All @@ -61,29 +74,34 @@ export const RuleDownloaderComponent = ({
window.URL.revokeObjectURL(objectURL);
}

onExportComplete(ruleIds.length);
if (onExportSuccess != null) {
onExportSuccess(ids.length);
}
}
} catch (error) {
if (isSubscribed) {
if (onExportFailure != null) {
onExportFailure();
}
errorToToaster({ title: i18n.EXPORT_FAILURE, error, dispatchToaster });
}
}
}
}
};

exportData();

return () => {
isSubscribed = false;
abortCtrl.abort();
};
}, [ruleIds]);
}, [ids]);

return <InvisibleAnchor ref={anchorRef} />;
};

RuleDownloaderComponent.displayName = 'RuleDownloaderComponent';
GenericDownloaderComponent.displayName = 'GenericDownloaderComponent';

export const RuleDownloader = React.memo(RuleDownloaderComponent);
export const GenericDownloader = React.memo(GenericDownloaderComponent);

RuleDownloader.displayName = 'RuleDownloader';
GenericDownloader.displayName = 'GenericDownloader';
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import { DeleteTimelineModal } from './delete_timeline_modal';
import * as i18n from '../translations';

describe('DeleteTimelineModal', () => {
test('it renders the expected title when a title is specified', () => {
test('it renders the expected title when a timeline is selected', () => {
const wrapper = mountWithIntl(
<DeleteTimelineModal
title="Privilege Escalation"
title={'Privilege Escalation'}
onDelete={jest.fn()}
closeModal={jest.fn()}
/>
Expand All @@ -29,10 +29,10 @@ describe('DeleteTimelineModal', () => {
).toEqual('Delete "Privilege Escalation"?');
});

test('it trims leading and trailing whitespace around the title', () => {
test('it trims leading whitespace around the title', () => {
const wrapper = mountWithIntl(
<DeleteTimelineModal
title=" Leading and trailing whitespace "
title={' Leading and trailing whitespace '}
onDelete={jest.fn()}
closeModal={jest.fn()}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@

import { EuiConfirmModal, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
import React, { useCallback } from 'react';
import { isEmpty } from 'lodash/fp';

import * as i18n from '../translations';

Expand All @@ -21,27 +22,34 @@ export const DELETE_TIMELINE_MODAL_WIDTH = 600; // px
/**
* Renders a modal that confirms deletion of a timeline
*/
export const DeleteTimelineModal = React.memo<Props>(({ title, closeModal, onDelete }) => (
<EuiConfirmModal
title={
export const DeleteTimelineModal = React.memo<Props>(({ title, closeModal, onDelete }) => {
const getTitle = useCallback(() => {
const trimmedTitle = title != null ? title.trim() : '';
const titleResult = !isEmpty(trimmedTitle) ? trimmedTitle : i18n.UNTITLED_TIMELINE;
return (
<FormattedMessage
id="xpack.siem.open.timeline.deleteTimelineModalTitle"
data-test-subj="title"
defaultMessage='Delete "{title}"?'
data-test-subj="title"
values={{
title: title != null && title.trim().length > 0 ? title.trim() : i18n.UNTITLED_TIMELINE,
title: titleResult,
}}
/>
}
onCancel={closeModal}
onConfirm={onDelete}
cancelButtonText={i18n.CANCEL}
confirmButtonText={i18n.DELETE}
buttonColor="danger"
defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON}
>
<div data-test-subj="warning">{i18n.DELETE_WARNING}</div>
</EuiConfirmModal>
));
);
}, [title]);
return (
<EuiConfirmModal
buttonColor="danger"
cancelButtonText={i18n.CANCEL}
confirmButtonText={i18n.DELETE}
defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON}
onCancel={closeModal}
onConfirm={onDelete}
title={getTitle()}
>
<div data-test-subj="warning">{i18n.DELETE_WARNING}</div>
</EuiConfirmModal>
);
});

DeleteTimelineModal.displayName = 'DeleteTimelineModal';
Original file line number Diff line number Diff line change
Expand Up @@ -4,114 +4,54 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { EuiButtonIconProps } from '@elastic/eui';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import React from 'react';

import { DeleteTimelineModalButton } from '.';
import { DeleteTimelineModalOverlay } from '.';

describe('DeleteTimelineModal', () => {
const savedObjectId = 'abcd';
const defaultProps = {
closeModal: jest.fn(),
deleteTimelines: jest.fn(),
isModalOpen: true,
savedObjectIds: [savedObjectId],
title: 'Privilege Escalation',
};

describe('showModalState', () => {
test('it disables the delete icon if deleteTimelines is not provided', () => {
const wrapper = mountWithIntl(
<DeleteTimelineModalButton savedObjectId={savedObjectId} title="Privilege Escalation" />
);
test('it does NOT render the modal when isModalOpen is false', () => {
const testProps = {
...defaultProps,
isModalOpen: false,
};
const wrapper = mountWithIntl(<DeleteTimelineModalOverlay {...testProps} />);

const props = wrapper
.find('[data-test-subj="delete-timeline"]')
.first()
.props() as EuiButtonIconProps;

expect(props.isDisabled).toBe(true);
});

test('it disables the delete icon if savedObjectId is null', () => {
const wrapper = mountWithIntl(
<DeleteTimelineModalButton
deleteTimelines={jest.fn()}
savedObjectId={null}
title="Privilege Escalation"
/>
);

const props = wrapper
.find('[data-test-subj="delete-timeline"]')
.first()
.props() as EuiButtonIconProps;

expect(props.isDisabled).toBe(true);
});

test('it disables the delete icon if savedObjectId is an empty string', () => {
const wrapper = mountWithIntl(
<DeleteTimelineModalButton
deleteTimelines={jest.fn()}
savedObjectId=""
title="Privilege Escalation"
/>
);

const props = wrapper
.find('[data-test-subj="delete-timeline"]')
.first()
.props() as EuiButtonIconProps;

expect(props.isDisabled).toBe(true);
});

test('it enables the delete icon if savedObjectId is NOT an empty string', () => {
const wrapper = mountWithIntl(
<DeleteTimelineModalButton
deleteTimelines={jest.fn()}
savedObjectId="not an empty string"
title="Privilege Escalation"
/>
);

const props = wrapper
.find('[data-test-subj="delete-timeline"]')
.first()
.props() as EuiButtonIconProps;

expect(props.isDisabled).toBe(false);
expect(
wrapper
.find('[data-test-subj="delete-timeline-modal"]')
.first()
.exists()
).toBe(false);
});

test('it does NOT render the modal when showModal is false', () => {
const wrapper = mountWithIntl(
<DeleteTimelineModalButton
deleteTimelines={jest.fn()}
savedObjectId={savedObjectId}
title="Privilege Escalation"
/>
);
test('it renders the modal when isModalOpen is true', () => {
const wrapper = mountWithIntl(<DeleteTimelineModalOverlay {...defaultProps} />);

expect(
wrapper
.find('[data-test-subj="delete-timeline-modal"]')
.first()
.exists()
).toBe(false);
).toBe(true);
});

test('it renders the modal when showModal is clicked', () => {
const wrapper = mountWithIntl(
<DeleteTimelineModalButton
deleteTimelines={jest.fn()}
savedObjectId={savedObjectId}
title="Privilege Escalation"
/>
);

wrapper
.find('[data-test-subj="delete-timeline"]')
.first()
.simulate('click');
test('it hides popover when isModalOpen is true', () => {
const wrapper = mountWithIntl(<DeleteTimelineModalOverlay {...defaultProps} />);

expect(
wrapper
.find('[data-test-subj="delete-timeline-modal"]')
.find('[data-test-subj="remove-popover"]')
.first()
.exists()
).toBe(true);
Expand Down
Loading

0 comments on commit ab44099

Please sign in to comment.