From 5d35a26603588d1b3f96808cad0942ada48aa72c Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Mon, 14 Dec 2020 21:27:34 -0500 Subject: [PATCH 01/18] wip --- .../events_viewer/events_viewer.tsx | 12 +- .../public/common/store/app/selectors.ts | 2 +- .../components/flyout/bottom_bar/index.tsx | 21 +- .../timelines/components/flyout/index.tsx | 4 +- .../components/timeline/header/index.tsx | 4 +- .../__snapshots__/index.test.tsx.snap | 294 +++++++++++++++ .../pinned_tab_content/index.test.tsx | 205 +++++++++++ .../timeline/pinned_tab_content/index.tsx | 340 ++++++++++++++++++ .../timeline/query_tab_content/index.tsx | 26 +- .../timeline/tabs_content/index.tsx | 59 ++- .../timeline/tabs_content/selectors.ts | 7 + .../events/all/query.events_all.dsl.ts | 22 +- 12 files changed, 954 insertions(+), 42 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index d6b2efbe43053..7559b986dfbfe 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -209,6 +209,12 @@ const EventsViewerComponent: React.FC = ({ queryFields, ]); + const prevSortField = useRef< + Array<{ + field: string; + direction: Direction; + }> + >([]); const sortField = useMemo( () => sort.map(({ columnId, sortDirection }) => ({ @@ -239,7 +245,11 @@ const EventsViewerComponent: React.FC = ({ prevCombinedQueries.current = combinedQueries; dispatch(timelineActions.toggleExpandedEvent({ timelineId: id })); } - }, [combinedQueries, dispatch, id]); + if (!deepEqual(prevSortField.current, sortField)) { + prevSortField.current = sortField; + dispatch(timelineActions.toggleExpandedEvent({ timelineId: id })); + } + }, [combinedQueries, dispatch, id, sortField]); const totalCountMinusDeleted = useMemo( () => (totalCount > 0 ? totalCount - deletedEventIds.length : 0), diff --git a/x-pack/plugins/security_solution/public/common/store/app/selectors.ts b/x-pack/plugins/security_solution/public/common/store/app/selectors.ts index 9808bbb1faed3..767091d0462e5 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/selectors.ts @@ -12,7 +12,7 @@ import { ErrorModel, NotesById } from './model'; import { State } from '../types'; import { TimelineResultNote } from '../../../timelines/components/open_timeline/types'; -const selectNotesById = (state: State): NotesById => state.app.notesById; +export const selectNotesById = (state: State): NotesById => state.app.notesById; const getErrors = (state: State): ErrorModel => state.app.errors; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx index 1c0f2ba55de41..79ab64678c173 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx @@ -61,16 +61,21 @@ const DataProvidersPanel = styled(EuiPanel)` `; interface FlyoutBottomBarProps { + showDataproviders: boolean; timelineId: string; } -export const FlyoutBottomBar = React.memo(({ timelineId }) => ( - - - - - - -)); +export const FlyoutBottomBar = React.memo( + ({ showDataproviders, timelineId }) => ( + + + {showDataproviders && ( + + + + )} + + ) +); FlyoutBottomBar.displayName = 'FlyoutBottomBar'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx index a1e61b9fa4ae6..db04aa5f07944 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx @@ -34,9 +34,7 @@ const FlyoutComponent: React.FC = ({ timelineId }) => { - - - + ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx index 248267fb2e052..5359491f15b01 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx @@ -19,6 +19,7 @@ import { interface Props { filterManager: FilterManager; + show: boolean; showCallOutUnauthorizedMsg: boolean; status: TimelineStatusLiteralWithNull; timelineId: string; @@ -26,6 +27,7 @@ interface Props { const TimelineHeaderComponent: React.FC = ({ filterManager, + show, showCallOutUnauthorizedMsg, status, timelineId, @@ -49,7 +51,7 @@ const TimelineHeaderComponent: React.FC = ({ size="s" /> )} - + {show && } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..20f5b61457e9f --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap @@ -0,0 +1,294 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Timeline rendering renders correctly against snapshot 1`] = ` + +`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx new file mode 100644 index 0000000000000..962e09d1a6237 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx @@ -0,0 +1,205 @@ +/* + * 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 { shallow } from 'enzyme'; +import React from 'react'; +import useResizeObserver from 'use-resize-observer/polyfilled'; + +import { Direction } from '../../../../graphql/types'; +import { defaultHeaders, mockTimelineData } from '../../../../common/mock'; +import '../../../../common/mock/match_media'; +import { TestProviders } from '../../../../common/mock/test_providers'; + +import { QueryTabContentComponent, Props as QueryTabContentComponentProps } from './index'; +import { Sort } from '../body/sort'; +import { mockDataProviders } from '../data_providers/mock/mock_data_providers'; +import { useMountAppended } from '../../../../common/utils/use_mount_appended'; +import { TimelineId, TimelineStatus } from '../../../../../common/types/timeline'; +import { useTimelineEvents } from '../../../containers/index'; +import { useTimelineEventsDetails } from '../../../containers/details/index'; +import { useSourcererScope } from '../../../../common/containers/sourcerer'; +import { mockSourcererScope } from '../../../../common/containers/sourcerer/mocks'; + +jest.mock('../../../containers/index', () => ({ + useTimelineEvents: jest.fn(), +})); +jest.mock('../../../containers/details/index', () => ({ + useTimelineEventsDetails: jest.fn(), +})); +jest.mock('../body/events/index', () => ({ + // eslint-disable-next-line react/display-name + Events: () => <>, +})); + +jest.mock('../../../../common/containers/sourcerer'); + +const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock; +jest.mock('use-resize-observer/polyfilled'); +mockUseResizeObserver.mockImplementation(() => ({})); + +jest.mock('../../../../common/lib/kibana', () => { + const originalModule = jest.requireActual('../../../../common/lib/kibana'); + return { + ...originalModule, + useKibana: jest.fn().mockReturnValue({ + services: { + application: { + navigateToApp: jest.fn(), + getUrlForApp: jest.fn(), + }, + uiSettings: { + get: jest.fn(), + }, + savedObjects: { + client: {}, + }, + }, + }), + useGetUserSavedObjectPermissions: jest.fn(), + }; +}); + +describe('Timeline', () => { + let props = {} as QueryTabContentComponentProps; + const sort: Sort[] = [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + ]; + const startDate = '2018-03-23T18:49:23.132Z'; + const endDate = '2018-03-24T03:33:52.253Z'; + + const mount = useMountAppended(); + + beforeEach(() => { + (useTimelineEvents as jest.Mock).mockReturnValue([ + false, + { + events: mockTimelineData, + pageInfo: { + activePage: 0, + totalPages: 10, + }, + }, + ]); + (useTimelineEventsDetails as jest.Mock).mockReturnValue([false, {}]); + + (useSourcererScope as jest.Mock).mockReturnValue(mockSourcererScope); + + props = { + columns: defaultHeaders, + dataProviders: mockDataProviders, + end: endDate, + expandedEvent: {}, + eventType: 'all', + showEventDetails: false, + filters: [], + timelineId: TimelineId.test, + isLive: false, + itemsPerPage: 5, + itemsPerPageOptions: [5, 10, 20], + kqlMode: 'search' as QueryTabContentComponentProps['kqlMode'], + kqlQueryExpression: '', + onEventClosed: jest.fn(), + showCallOutUnauthorizedMsg: false, + sort, + start: startDate, + status: TimelineStatus.active, + timerangeKind: 'absolute', + updateEventTypeAndIndexesName: jest.fn(), + }; + }); + + describe('rendering', () => { + test('renders correctly against snapshot', () => { + const wrapper = shallow( + + + + ); + + expect(wrapper.find('QueryTabContentComponent')).toMatchSnapshot(); + }); + + test('it renders the timeline header', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="timelineHeader"]').exists()).toEqual(true); + }); + + test('it renders the timeline table', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="events-table"]').exists()).toEqual(true); + }); + + test('it does NOT render the timeline table when the source is loading', () => { + (useSourcererScope as jest.Mock).mockReturnValue({ + browserFields: {}, + docValueFields: [], + loading: true, + indexPattern: {}, + selectedPatterns: [], + }); + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="events-table"]').exists()).toEqual(false); + }); + + test('it does NOT render the timeline table when start is empty', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="events-table"]').exists()).toEqual(false); + }); + + test('it does NOT render the timeline table when end is empty', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="events-table"]').exists()).toEqual(false); + }); + + test('it does NOT render the paging footer when you do NOT have any data providers', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="table-pagination"]').exists()).toEqual(false); + }); + + it('it shows the timeline footer', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="timeline-footer"]').exists()).toEqual(true); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx new file mode 100644 index 0000000000000..d79e04a85e865 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx @@ -0,0 +1,340 @@ +/* + * 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 { + EuiTabbedContent, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiBadge, +} from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; +import React, { useState, useMemo, useEffect, useCallback } from 'react'; +import styled from 'styled-components'; +import { Dispatch } from 'redux'; +import { connect, ConnectedProps } from 'react-redux'; +import deepEqual from 'fast-deep-equal'; +import { InPortal } from 'react-reverse-portal'; + +import { timelineActions, timelineSelectors } from '../../../store/timeline'; +import { Direction } from '../../../../../common/search_strategy'; +import { useTimelineEvents } from '../../../containers/index'; +import { useKibana } from '../../../../common/lib/kibana'; +import { defaultHeaders } from '../body/column_headers/default_headers'; +import { StatefulBody } from '../body'; +import { Footer, footerHeight } from '../footer'; +import { FilterManager } from '../../../../../../../../src/plugins/data/public'; +import { useManageTimeline } from '../../manage_timeline'; +import { requiredFieldsForActions } from '../../../../detections/components/alerts_table/default_config'; +import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context'; +import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; +import { timelineDefaults } from '../../../store/timeline/defaults'; +import { useSourcererScope } from '../../../../common/containers/sourcerer'; +import { useTimelinePinnedEventsCountPortal } from '../../../../common/hooks/use_timeline_events_count'; +import { TimelineModel } from '../../../store/timeline/model'; +import { EventDetails } from '../event_details'; +import { ToggleExpandedEvent } from '../../../store/timeline/actions'; +import { State } from '../../../../common/store'; + +const TimelineHeaderContainer = styled.div` + margin-top: 6px; + width: 100%; +`; + +TimelineHeaderContainer.displayName = 'TimelineHeaderContainer'; + +const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` + overflow-y: hidden; + flex: 1; + + .euiFlyoutBody__overflow { + overflow: hidden; + mask-image: none; + } + + .euiFlyoutBody__overflowContent { + padding: 0; + height: 100%; + display: flex; + } +`; + +const StyledEuiFlyoutFooter = styled(EuiFlyoutFooter)` + background: none; + padding: 0; +`; + +const FullWidthFlexGroup = styled(EuiFlexGroup)` + margin: 0; + width: 100%; + overflow: hidden; +`; + +const ScrollableFlexItem = styled(EuiFlexItem)` + overflow: hidden; +`; + +const DatePicker = styled(EuiFlexItem)` + .euiSuperDatePicker__flexWrapper { + max-width: none; + width: auto; + } +`; + +DatePicker.displayName = 'DatePicker'; + +const VerticalRule = styled.div` + width: 2px; + height: 100%; + background: ${({ theme }) => theme.eui.euiColorLightShade}; +`; + +VerticalRule.displayName = 'VerticalRule'; + +const StyledEuiTabbedContent = styled(EuiTabbedContent)` + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + + > [role='tabpanel'] { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + } +`; + +StyledEuiTabbedContent.displayName = 'StyledEuiTabbedContent'; + +const EventsCountBadge = styled(EuiBadge)` + margin-left: ${({ theme }) => theme.eui.paddingSizes.s}; +`; + +interface OwnProps { + timelineId: string; +} + +interface PinnedFilter { + bool: { + should: Array<{ match_phrase: { _id: string } }>; + minimum_should_match: number; + }; +} + +export type Props = OwnProps & PropsFromRedux; + +export const PinnedTabContentComponent: React.FC = ({ + columns, + timelineId, + itemsPerPage, + itemsPerPageOptions, + pinnedEventIds, + onEventClosed, + showEventDetails, + sort, +}) => { + const { browserFields, docValueFields, loading: loadingSourcerer } = useSourcererScope( + SourcererScopeName.timeline + ); + + const { uiSettings } = useKibana().services; + const [filterManager] = useState(new FilterManager(uiSettings)); + + const filterQuery = useMemo(() => { + const filterObj = Object.entries(pinnedEventIds).reduce( + (acc, [pinnedId, isPinned]) => { + if (isPinned) { + return { + ...acc, + bool: { + ...acc.bool, + should: [ + ...acc.bool.should, + { + match_phrase: { + _id: pinnedId, + }, + }, + ], + }, + }; + } + return acc; + }, + { + bool: { + should: [], + minimum_should_match: 1, + }, + } + ); + try { + return JSON.stringify(filterObj); + } catch { + return ''; + } + }, [pinnedEventIds]); + + const timelineQueryFields = useMemo(() => { + const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; + const columnFields = columnsHeader.map((c) => c.id); + + return [...columnFields, ...requiredFieldsForActions]; + }, [columns]); + + const timelineQuerySortField = useMemo( + () => + sort.map(({ columnId, sortDirection }) => ({ + field: columnId, + direction: sortDirection as Direction, + })), + [sort] + ); + + const { initializeTimeline } = useManageTimeline(); + useEffect(() => { + initializeTimeline({ + filterManager, + id: timelineId, + }); + }, [initializeTimeline, filterManager, timelineId]); + + const [ + isQueryLoading, + { events, totalCount, pageInfo, loadPage, updatedAt, refetch }, + ] = useTimelineEvents({ + docValueFields, + endDate: '', + id: `pinned-${timelineId}`, + indexNames: [''], + fields: timelineQueryFields, + limit: itemsPerPage, + filterQuery, + skip: filterQuery === '', + startDate: '', + sort: timelineQuerySortField, + timerangeKind: undefined, + }); + + const handleOnEventClosed = useCallback(() => { + onEventClosed({ timelineId }); + }, [timelineId, onEventClosed]); + + useEffect(() => { + handleOnEventClosed(); + return () => { + handleOnEventClosed(); + }; + }, [handleOnEventClosed]); + + return ( + <> + + + + + + + +