Skip to content

Commit

Permalink
[SIEM][Timeline] Persist timeline to localStorage (#67156)
Browse files Browse the repository at this point in the history
Co-authored-by: Elastic Machine <[email protected]>
Co-authored-by: Xavier Mouligneau <[email protected]>
  • Loading branch information
3 people authored Jun 17, 2020
1 parent ab1270e commit 47e50f8
Show file tree
Hide file tree
Showing 70 changed files with 949 additions and 163 deletions.
26 changes: 26 additions & 0 deletions x-pack/plugins/security_solution/common/types/timeline/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,32 @@ export interface SavedTimeline extends runtimeTypes.TypeOf<typeof SavedTimelineR

export interface SavedTimelineNote extends runtimeTypes.TypeOf<typeof SavedTimelineRuntimeType> {}

/*
* Timeline IDs
*/

export enum TimelineId {
hostsPageEvents = 'hosts-page-events',
hostsPageExternalAlerts = 'hosts-page-external-alerts',
alertsRulesDetailsPage = 'alerts-rules-details-page',
alertsPage = 'alerts-page',
networkPageExternalAlerts = 'network-page-external-alerts',
active = 'timeline-1',
test = 'test', // Reserved for testing purposes
}

export const TimelineIdLiteralRt = runtimeTypes.union([
runtimeTypes.literal(TimelineId.hostsPageEvents),
runtimeTypes.literal(TimelineId.hostsPageExternalAlerts),
runtimeTypes.literal(TimelineId.alertsRulesDetailsPage),
runtimeTypes.literal(TimelineId.alertsPage),
runtimeTypes.literal(TimelineId.networkPageExternalAlerts),
runtimeTypes.literal(TimelineId.active),
runtimeTypes.literal(TimelineId.test),
]);

export type TimelineIdLiteral = runtimeTypes.TypeOf<typeof TimelineIdLiteralRt>;

/**
* Timeline Saved object type with metadata
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ import {
dragAndDropColumn,
openEventsViewerFieldsBrowser,
opensInspectQueryModal,
resetFields,
waitsForEventsToBeLoaded,
} from '../tasks/hosts/events';
import { clearSearchBar, kqlSearch } from '../tasks/security_header';

import { HOSTS_PAGE } from '../urls/navigation';
import { resetFields } from '../tasks/timeline';

const defaultHeadersInDefaultEcsCategory = [
{ id: '@timestamp' },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { reload } from '../tasks/common';
import { loginAndWaitForPage } from '../tasks/login';
import { HOSTS_PAGE } from '../urls/navigation';
import { openEvents } from '../tasks/hosts/main';
import { DRAGGABLE_HEADER } from '../screens/timeline';
import { TABLE_COLUMN_EVENTS_MESSAGE } from '../screens/hosts/external_events';
import { waitsForEventsToBeLoaded, openEventsViewerFieldsBrowser } from '../tasks/hosts/events';
import { removeColumn, resetFields } from '../tasks/timeline';

describe('persistent timeline', () => {
before(() => {
loginAndWaitForPage(HOSTS_PAGE);
openEvents();
waitsForEventsToBeLoaded();
});

afterEach(() => {
openEventsViewerFieldsBrowser();
resetFields();
});

it('persist the deletion of a column', () => {
cy.get(DRAGGABLE_HEADER).then((header) => {
const currentNumberOfTimelineColumns = header.length;
const expectedNumberOfTimelineColumns = currentNumberOfTimelineColumns - 1;

cy.wrap(header).eq(TABLE_COLUMN_EVENTS_MESSAGE).invoke('text').should('equal', 'message');
removeColumn(TABLE_COLUMN_EVENTS_MESSAGE);

cy.get(DRAGGABLE_HEADER).should('have.length', expectedNumberOfTimelineColumns);

reload(waitsForEventsToBeLoaded);

cy.get(DRAGGABLE_HEADER).should('have.length', expectedNumberOfTimelineColumns);
cy.get(DRAGGABLE_HEADER).each(($el) => {
expect($el.text()).not.equal('message');
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,4 @@ export const LOCAL_EVENTS_COUNT =
export const LOAD_MORE =
'[data-test-subj="events-viewer-panel"] [data-test-subj="TimelineMoreButton"';

export const RESET_FIELDS =
'[data-test-subj="events-viewer-panel"] [data-test-subj="reset-fields"]';

export const SERVER_SIDE_EVENT_COUNT = '[data-test-subj="server-side-event-count"]';
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

export const TABLE_COLUMN_EVENTS_MESSAGE = 1;
7 changes: 7 additions & 0 deletions x-pack/plugins/security_solution/cypress/screens/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export const ID_TOGGLE_FIELD = '[data-test-subj="toggle-field-_id"]';

export const PROVIDER_BADGE = '[data-test-subj="providerBadge"]';

export const REMOVE_COLUMN = '[data-test-subj="remove-column"]';

export const RESET_FIELDS =
'[data-test-subj="events-viewer-panel"] [data-test-subj="reset-fields"]';

export const SEARCH_OR_FILTER_CONTAINER =
'[data-test-subj="timeline-search-or-filter-search-container"]';

Expand All @@ -30,6 +35,8 @@ export const TIMELINE = (id: string) => {
return `[data-test-subj="title-${id}"]`;
};

export const TIMELINE_COLUMN_SPINNER = '[data-test-subj="timeline-loading-spinner"]';

export const TIMELINE_DATA_PROVIDERS = '[data-test-subj="dataProviders"]';

export const TIMELINE_DATA_PROVIDERS_EMPTY =
Expand Down
6 changes: 6 additions & 0 deletions x-pack/plugins/security_solution/cypress/tasks/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,9 @@ export const drop = (dropTarget: JQuery<HTMLElement>) => {
.trigger('mouseup', { force: true })
.wait(1000);
};

export const reload = (afterReload: () => void) => {
cy.reload();
cy.contains('a', 'Security');
afterReload();
};
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
HOST_GEO_COUNTRY_NAME_CHECKBOX,
INSPECT_QUERY,
LOAD_MORE,
RESET_FIELDS,
SERVER_SIDE_EVENT_COUNT,
} from '../../screens/hosts/events';
import { DRAGGABLE_HEADER } from '../../screens/timeline';
Expand Down Expand Up @@ -53,10 +52,6 @@ export const opensInspectQueryModal = () => {
.click({ force: true });
};

export const resetFields = () => {
cy.get(RESET_FIELDS).click({ force: true });
};

export const waitsForEventsToBeLoaded = () => {
cy.get(SERVER_SIDE_EVENT_COUNT).should('exist').invoke('text').should('not.equal', '0');
};
Expand Down
11 changes: 11 additions & 0 deletions x-pack/plugins/security_solution/cypress/tasks/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
TIMELINE_TITLE,
TIMESTAMP_TOGGLE_FIELD,
TOGGLE_TIMELINE_EXPAND_EVENT,
REMOVE_COLUMN,
RESET_FIELDS,
} from '../screens/timeline';

import { drag, drop } from '../tasks/common';
Expand Down Expand Up @@ -101,3 +103,12 @@ export const dragAndDropIdToggleFieldToTimeline = () => {
drop(headersDropArea)
);
};

export const removeColumn = (column: number) => {
cy.get(REMOVE_COLUMN).first().should('exist');
cy.get(REMOVE_COLUMN).eq(column).click({ force: true });
};

export const resetFields = () => {
cy.get(RESET_FIELDS).click({ force: true });
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
import React from 'react';
import { shallow } from 'enzyme';

import { TimelineId } from '../../../../common/types/timeline';
import { AlertsTableComponent } from './index';

describe('AlertsTableComponent', () => {
it('renders correctly', () => {
const wrapper = shallow(
<AlertsTableComponent
timelineId={TimelineId.test}
canUserCRUD
hasIndexWrite
from={0}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Dispatch } from 'redux';

import { Status } from '../../../../common/detection_engine/schemas/common/schemas';
import { Filter, esQuery } from '../../../../../../../src/plugins/data/public';
import { TimelineIdLiteral } from '../../../../common/types/timeline';
import { useFetchIndexPatterns } from '../../../alerts/containers/detection_engine/rules/fetch_index_patterns';
import { StatefulEventsViewer } from '../../../common/components/events_viewer';
import { HeaderSection } from '../../../common/components/header_section';
Expand Down Expand Up @@ -48,9 +49,8 @@ import {
displayErrorToast,
} from '../../../common/components/toasters';

export const ALERTS_TABLE_TIMELINE_ID = 'alerts-table';

interface OwnProps {
timelineId: TimelineIdLiteral;
canUserCRUD: boolean;
defaultFilters?: Filter[];
hasIndexWrite: boolean;
Expand All @@ -63,6 +63,7 @@ interface OwnProps {
type AlertsTableComponentProps = OwnProps & PropsFromRedux;

export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
timelineId,
canUserCRUD,
clearEventsDeleted,
clearEventsLoading,
Expand Down Expand Up @@ -140,18 +141,16 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({

const setEventsLoadingCallback = useCallback(
({ eventIds, isLoading }: SetEventsLoadingProps) => {
setEventsLoading!({ id: ALERTS_TABLE_TIMELINE_ID, eventIds, isLoading });
setEventsLoading!({ id: timelineId, eventIds, isLoading });
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[setEventsLoading, ALERTS_TABLE_TIMELINE_ID]
[setEventsLoading, timelineId]
);

const setEventsDeletedCallback = useCallback(
({ eventIds, isDeleted }: SetEventsDeletedProps) => {
setEventsDeleted!({ id: ALERTS_TABLE_TIMELINE_ID, eventIds, isDeleted });
setEventsDeleted!({ id: timelineId, eventIds, isDeleted });
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[setEventsDeleted, ALERTS_TABLE_TIMELINE_ID]
[setEventsDeleted, timelineId]
);

const onAlertStatusUpdateSuccess = useCallback(
Expand Down Expand Up @@ -202,20 +201,20 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
// Callback for when open/closed filter changes
const onFilterGroupChangedCallback = useCallback(
(newFilterGroup: Status) => {
clearEventsLoading!({ id: ALERTS_TABLE_TIMELINE_ID });
clearEventsDeleted!({ id: ALERTS_TABLE_TIMELINE_ID });
clearSelected!({ id: ALERTS_TABLE_TIMELINE_ID });
clearEventsLoading!({ id: timelineId });
clearEventsDeleted!({ id: timelineId });
clearSelected!({ id: timelineId });
setFilterGroup(newFilterGroup);
},
[clearEventsLoading, clearEventsDeleted, clearSelected, setFilterGroup]
[clearEventsLoading, clearEventsDeleted, clearSelected, setFilterGroup, timelineId]
);

// Callback for clearing entire selection from utility bar
const clearSelectionCallback = useCallback(() => {
clearSelected!({ id: ALERTS_TABLE_TIMELINE_ID });
clearSelected!({ id: timelineId });
setSelectAll(false);
setShowClearSelectionAction(false);
}, [clearSelected, setSelectAll, setShowClearSelectionAction]);
}, [clearSelected, setSelectAll, setShowClearSelectionAction, timelineId]);

// Callback for selecting all events on all pages from utility bar
// Dispatches to stateful_body's selectAll via TimelineTypeContext props
Expand Down Expand Up @@ -327,7 +326,7 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({

useEffect(() => {
initializeTimeline({
id: ALERTS_TABLE_TIMELINE_ID,
id: timelineId,
documentType: i18n.ALERTS_DOCUMENT_TYPE,
footerText: i18n.TOTAL_COUNT_OF_ALERTS,
loadingText: i18n.LOADING_ALERTS,
Expand All @@ -338,7 +337,7 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
}, []);
useEffect(() => {
setTimelineRowActions({
id: ALERTS_TABLE_TIMELINE_ID,
id: timelineId,
queryFields: requiredFieldsForActions,
timelineRowActions: additionalActions,
});
Expand All @@ -365,7 +364,7 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
defaultModel={alertsDefaultModel}
end={to}
headerFilterGroup={headerFilterGroup}
id={ALERTS_TABLE_TIMELINE_ID}
id={timelineId}
start={from}
utilityBar={utilityBarCallback}
/>
Expand All @@ -375,9 +374,9 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
const makeMapStateToProps = () => {
const getTimeline = timelineSelectors.getTimelineByIdSelector();
const getGlobalInputs = inputsSelectors.globalSelector();
const mapStateToProps = (state: State) => {
const timeline: TimelineModel =
getTimeline(state, ALERTS_TABLE_TIMELINE_ID) ?? timelineDefaults;
const mapStateToProps = (state: State, ownProps: OwnProps) => {
const { timelineId } = ownProps;
const timeline: TimelineModel = getTimeline(state, timelineId) ?? timelineDefaults;
const { deletedEventIds, isSelectAllChecked, loadingEventIds, selectedEventIds } = timeline;

const globalInputs: inputsModel.InputsRange = getGlobalInputs(state);
Expand Down
8 changes: 8 additions & 0 deletions x-pack/plugins/security_solution/public/alerts/constants.ts
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;
* you may not use this file except in compliance with the Elastic License.
*/

export const ALERTS_RULES_DETAILS_PAGE_TIMELINE_ID = 'alerts-rules-details-page';
export const ALERTS_TIMELINE_ID = 'alerts-page';
13 changes: 12 additions & 1 deletion x-pack/plugins/security_solution/public/alerts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,26 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { Storage } from '../../../../../src/plugins/kibana_utils/public';
import { getTimelinesInStorageByIds } from '../timelines/containers/local_storage';
import { TimelineIdLiteral, TimelineId } from '../../common/types/timeline';
import { getAlertsRoutes } from './routes';
import { SecuritySubPlugin } from '../app/types';

const ALERTS_TIMELINE_IDS: TimelineIdLiteral[] = [
TimelineId.alertsRulesDetailsPage,
TimelineId.alertsPage,
];

export class Alerts {
public setup() {}

public start(): SecuritySubPlugin {
public start(storage: Storage): SecuritySubPlugin {
return {
routes: getAlertsRoutes(),
storageTimelines: {
timelineById: getTimelinesInStorageByIds(storage, ALERTS_TIMELINE_IDS),
},
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import React, { useCallback, useMemo } from 'react';
import { StickyContainer } from 'react-sticky';
import { connect, ConnectedProps } from 'react-redux';

import { TimelineId } from '../../../../common/types/timeline';
import { GlobalTime } from '../../../common/containers/global_time';
import {
indicesExistOrDataTemporarilyUnavailable,
Expand Down Expand Up @@ -138,6 +139,7 @@ export const DetectionEnginePageComponent: React.FC<PropsFromRedux> = ({
/>
<EuiSpacer size="l" />
<AlertsTable
timelineId={TimelineId.alertsPage}
loading={loading}
hasIndexWrite={hasIndexWrite ?? false}
canUserCRUD={(canUserCRUD ?? false) && (hasEncryptionKey ?? false)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { Redirect, useParams } from 'react-router-dom';
import { StickyContainer } from 'react-sticky';
import { connect, ConnectedProps } from 'react-redux';

import { TimelineId } from '../../../../../../common/types/timeline';
import { UpdateDateRange } from '../../../../../common/components/charts/common';
import { FiltersGlobal } from '../../../../../common/components/filters_global';
import { FormattedDate } from '../../../../../common/components/formatted_date';
Expand Down Expand Up @@ -386,6 +387,7 @@ export const RuleDetailsPageComponent: FC<PropsFromRedux> = ({
<EuiSpacer />
{ruleId != null && (
<AlertsTable
timelineId={TimelineId.alertsRulesDetailsPage}
canUserCRUD={canUserCRUD ?? false}
defaultFilters={alertDefaultFilters}
hasIndexWrite={hasIndexWrite ?? false}
Expand Down
Loading

0 comments on commit 47e50f8

Please sign in to comment.