diff --git a/x-pack/legacy/plugins/siem/common/constants.ts b/x-pack/legacy/plugins/siem/common/constants.ts index 3b5cdec4dc4d..91e6d9d5e3be 100644 --- a/x-pack/legacy/plugins/siem/common/constants.ts +++ b/x-pack/legacy/plugins/siem/common/constants.ts @@ -11,6 +11,7 @@ export const DEFAULT_DATE_FORMAT = 'dateFormat'; export const DEFAULT_DATE_FORMAT_TZ = 'dateFormat:tz'; export const DEFAULT_DARK_MODE = 'theme:darkMode'; export const DEFAULT_INDEX_KEY = 'siem:defaultIndex'; +export const DEFAULT_NUMBER_FORMAT = 'format:number:defaultPattern'; export const DEFAULT_TIME_RANGE = 'timepicker:timeDefaults'; export const DEFAULT_REFRESH_RATE_INTERVAL = 'timepicker:refreshIntervalDefaults'; export const DEFAULT_SIEM_TIME_RANGE = 'siem:timeDefaults'; diff --git a/x-pack/legacy/plugins/siem/public/components/alerts_viewer/alerts_table.tsx b/x-pack/legacy/plugins/siem/public/components/alerts_viewer/alerts_table.tsx index e0101dc3ab74..8fa4f3625c34 100644 --- a/x-pack/legacy/plugins/siem/public/components/alerts_viewer/alerts_table.tsx +++ b/x-pack/legacy/plugins/siem/public/components/alerts_viewer/alerts_table.tsx @@ -64,7 +64,7 @@ export const AlertsTable = React.memo( const alertsFilter = useMemo(() => [...defaultAlertsFilters, ...pageFilters], [pageFilters]); return ( ({ documentType: i18n.ALERTS_DOCUMENT_TYPE, footerText: i18n.TOTAL_COUNT_OF_ALERTS, - showCheckboxes: false, - showRowRenderers: false, title: i18n.ALERTS_TABLE_TITLE, }), [] diff --git a/x-pack/legacy/plugins/siem/public/components/alerts_viewer/default_headers.ts b/x-pack/legacy/plugins/siem/public/components/alerts_viewer/default_headers.ts index 52990f521b58..936d43fff0b4 100644 --- a/x-pack/legacy/plugins/siem/public/components/alerts_viewer/default_headers.ts +++ b/x-pack/legacy/plugins/siem/public/components/alerts_viewer/default_headers.ts @@ -65,4 +65,5 @@ export const alertsHeaders: ColumnHeader[] = [ export const alertsDefaultModel: SubsetTimelineModel = { ...timelineDefaults, columns: alertsHeaders, + showRowRenderers: false, }; diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx index 9878194a1782..7c4369e952d6 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx @@ -5,8 +5,8 @@ */ import { EuiPanel } from '@elastic/eui'; -import { getOr, isEmpty, isEqual } from 'lodash/fp'; -import React from 'react'; +import { getOr, isEmpty, isEqual, union } from 'lodash/fp'; +import React, { useMemo } from 'react'; import styled from 'styled-components'; import { BrowserFields } from '../../containers/source'; @@ -46,6 +46,7 @@ interface Props { browserFields: BrowserFields; columns: ColumnHeader[]; dataProviders: DataProvider[]; + deletedEventIds: Readonly; end: number; filters: esFilters.Filter[]; headerFilterGroup?: React.ReactNode; @@ -71,6 +72,7 @@ export const EventsViewer = React.memo( browserFields, columns, dataProviders, + deletedEventIds, end, filters, headerFilterGroup, @@ -104,6 +106,14 @@ export const EventsViewer = React.memo( end, isEventViewer: true, }); + const queryFields = useMemo( + () => + union( + columnsHeader.map(c => c.id), + timelineTypeContext.queryFields ?? [] + ), + [columnsHeader, timelineTypeContext.queryFields] + ); return ( @@ -119,7 +129,7 @@ export const EventsViewer = React.memo( {combinedQueries != null ? ( c.id)} + fields={queryFields} filterQuery={combinedQueries.filterQuery} id={id} indexPattern={indexPattern} @@ -139,73 +149,81 @@ export const EventsViewer = React.memo( pageInfo, refetch, totalCount = 0, - }) => ( - <> - - {headerFilterGroup} - + }) => { + const totalCountMinusDeleted = + totalCount > 0 ? totalCount - deletedEventIds.length : 0; - {utilityBar?.(totalCount)} - -
- + - + + {utilityBar?.(totalCountMinusDeleted)} + +
+ + width={width} + type={timelineTypeContext} + > + - + !deletedEventIds.includes(e._id))} + id={id} + isEventViewer={true} + height={height} + sort={sort} + toggleColumn={toggleColumn} + /> -
- -
- - )} +
+ +
+ + ); + }}
) : null} @@ -218,6 +236,7 @@ export const EventsViewer = React.memo( prevProps.browserFields === nextProps.browserFields && prevProps.columns === nextProps.columns && prevProps.dataProviders === nextProps.dataProviders && + prevProps.deletedEventIds === nextProps.deletedEventIds && prevProps.end === nextProps.end && isEqual(prevProps.filters, nextProps.filters) && prevProps.height === nextProps.height && @@ -230,6 +249,8 @@ export const EventsViewer = React.memo( isEqual(prevProps.query, nextProps.query) && prevProps.showInspect === nextProps.showInspect && prevProps.start === nextProps.start && - prevProps.sort === nextProps.sort + prevProps.sort === nextProps.sort && + isEqual(prevProps.timelineTypeContext, nextProps.timelineTypeContext) && + prevProps.utilityBar === nextProps.utilityBar ); EventsViewer.displayName = 'EventsViewer'; diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx index b614776cd90c..385352a62d5b 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx @@ -5,7 +5,7 @@ */ import { isEqual } from 'lodash/fp'; -import React, { useCallback, useEffect, useState, useMemo } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { connect } from 'react-redux'; import { ActionCreator } from 'typescript-fsa'; import chrome from 'ui/chrome'; @@ -23,10 +23,10 @@ import { InputsModelId } from '../../store/inputs/constants'; import { useFetchIndexPatterns } from '../../containers/detection_engine/rules/fetch_index_patterns'; import { TimelineTypeContextProps } from '../timeline/timeline_context'; import { DEFAULT_INDEX_KEY } from '../../../common/constants'; +import * as i18n from './translations'; export interface OwnProps { defaultIndices?: string[]; - defaultFilters?: esFilters.Filter[]; defaultModel: SubsetTimelineModel; end: number; id: string; @@ -38,7 +38,6 @@ export interface OwnProps { } interface StateReduxProps { - activePage?: number; columns: ColumnHeader[]; dataProviders?: DataProvider[]; filters: esFilters.Filter[]; @@ -46,9 +45,12 @@ interface StateReduxProps { itemsPerPage?: number; itemsPerPageOptions?: number[]; kqlMode: KqlMode; + deletedEventIds: Readonly; query: Query; pageCount?: number; sort?: Sort; + showCheckboxes: boolean; + showRowRenderers: boolean; } interface DispatchProps { @@ -57,6 +59,8 @@ interface DispatchProps { columns: ColumnHeader[]; itemsPerPage?: number; sort?: Sort; + showCheckboxes?: boolean; + showRowRenderers?: boolean; }>; deleteEventQuery: ActionCreator<{ id: string; @@ -84,8 +88,8 @@ const StatefulEventsViewerComponent = React.memo( createTimeline, columns, dataProviders, - defaultFilters = [], defaultModel, + deletedEventIds, defaultIndices, deleteEventQuery, end, @@ -96,13 +100,15 @@ const StatefulEventsViewerComponent = React.memo( itemsPerPage, itemsPerPageOptions, kqlMode, + pageFilters = [], query, removeColumn, start, + showCheckboxes, + showRowRenderers, sort, timelineTypeContext = { - showCheckboxes: false, - showRowRenderers: true, + loadingText: i18n.LOADING_EVENTS, }, updateItemsPerPage, upsertColumn, @@ -115,7 +121,7 @@ const StatefulEventsViewerComponent = React.memo( useEffect(() => { if (createTimeline != null) { - createTimeline({ id, columns, sort, itemsPerPage }); + createTimeline({ id, columns, sort, itemsPerPage, showCheckboxes, showRowRenderers }); } return () => { deleteEventQuery({ id, inputId: 'global' }); @@ -151,7 +157,7 @@ const StatefulEventsViewerComponent = React.memo( const handleOnMouseEnter = useCallback(() => setShowInspect(true), []); const handleOnMouseLeave = useCallback(() => setShowInspect(false), []); - const eventsFilter = useMemo(() => [...filters], [defaultFilters]); + return (
( columns={columns} id={id} dataProviders={dataProviders!} + deletedEventIds={deletedEventIds} end={end} - filters={eventsFilter} + filters={filters} headerFilterGroup={headerFilterGroup} indexPattern={indexPatterns ?? { fields: [], title: '' }} isLive={isLive} @@ -181,9 +188,9 @@ const StatefulEventsViewerComponent = React.memo( }, (prevProps, nextProps) => prevProps.id === nextProps.id && - prevProps.activePage === nextProps.activePage && isEqual(prevProps.columns, nextProps.columns) && isEqual(prevProps.dataProviders, nextProps.dataProviders) && + prevProps.deletedEventIds === nextProps.deletedEventIds && prevProps.end === nextProps.end && isEqual(prevProps.filters, nextProps.filters) && prevProps.isLive === nextProps.isLive && @@ -194,7 +201,12 @@ const StatefulEventsViewerComponent = React.memo( prevProps.pageCount === nextProps.pageCount && isEqual(prevProps.sort, nextProps.sort) && prevProps.start === nextProps.start && - isEqual(prevProps.defaultFilters, nextProps.defaultFilters) + isEqual(prevProps.pageFilters, nextProps.pageFilters) && + prevProps.showCheckboxes === nextProps.showCheckboxes && + prevProps.showRowRenderers === nextProps.showRowRenderers && + prevProps.start === nextProps.start && + isEqual(prevProps.timelineTypeContext, nextProps.timelineTypeContext) && + prevProps.utilityBar === nextProps.utilityBar ); StatefulEventsViewerComponent.displayName = 'StatefulEventsViewerComponent'; @@ -204,15 +216,26 @@ const makeMapStateToProps = () => { const getGlobalQuerySelector = inputsSelectors.globalQuerySelector(); const getGlobalFiltersQuerySelector = inputsSelectors.globalFiltersQuerySelector(); const getEvents = timelineSelectors.getEventsByIdSelector(); - const mapStateToProps = (state: State, { id, defaultFilters = [], defaultModel }: OwnProps) => { + const mapStateToProps = (state: State, { id, pageFilters = [], defaultModel }: OwnProps) => { const input: inputsModel.InputsRange = getInputsTimeline(state); const events: TimelineModel = getEvents(state, id) ?? defaultModel; - const { columns, dataProviders, itemsPerPage, itemsPerPageOptions, kqlMode, sort } = events; + const { + columns, + dataProviders, + deletedEventIds, + itemsPerPage, + itemsPerPageOptions, + kqlMode, + sort, + showCheckboxes, + showRowRenderers, + } = events; return { columns, dataProviders, - filters: [...getGlobalFiltersQuerySelector(state), ...defaultFilters], + deletedEventIds, + filters: [...getGlobalFiltersQuerySelector(state), ...pageFilters], id, isLive: input.policy.kind === 'interval', itemsPerPage, @@ -220,6 +243,8 @@ const makeMapStateToProps = () => { kqlMode, query: getGlobalQuerySelector(state), sort, + showCheckboxes, + showRowRenderers, }; }; return mapStateToProps; @@ -231,5 +256,4 @@ export const StatefulEventsViewer = connect(makeMapStateToProps, { updateItemsPerPage: timelineActions.updateItemsPerPage, removeColumn: timelineActions.removeColumn, upsertColumn: timelineActions.upsertColumn, - setSearchBarFilter: inputsActions.setSearchBarFilter, })(StatefulEventsViewerComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/translations.ts b/x-pack/legacy/plugins/siem/public/components/events_viewer/translations.ts index f1a844f84ada..6e6be02a6085 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/translations.ts @@ -14,6 +14,13 @@ export const EVENTS = i18n.translate('xpack.siem.eventsViewer.eventsLabel', { defaultMessage: 'Events', }); +export const LOADING_EVENTS = i18n.translate( + 'xpack.siem.eventsViewer.footer.loadingEventsDataLabel', + { + defaultMessage: 'Loading Events', + } +); + export const UNIT = (totalCount: number) => i18n.translate('xpack.siem.eventsViewer.unit', { values: { totalCount }, diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.test.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.test.ts index 840d8c0a4812..b56fae320df1 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.test.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.test.ts @@ -234,6 +234,7 @@ describe('helpers', () => { start: 0, }, description: '', + deletedEventIds: [], eventIdToNoteIds: {}, filters: [], highlightedDropAndProviderId: '', @@ -241,6 +242,7 @@ describe('helpers', () => { id: 'savedObject-1', isFavorite: false, isLive: false, + isSelectAllChecked: false, isLoading: false, isSaving: false, itemsPerPage: 25, @@ -250,11 +252,15 @@ describe('helpers', () => { filterQuery: null, filterQueryDraft: null, }, + loadingEventIds: [], noteIds: [], pinnedEventIds: {}, pinnedEventsSaveObject: {}, savedObjectId: 'savedObject-1', + selectedEventIds: {}, show: false, + showCheckboxes: false, + showRowRenderers: true, sort: { columnId: '@timestamp', sortDirection: 'desc', @@ -321,6 +327,7 @@ describe('helpers', () => { start: 0, }, description: '', + deletedEventIds: [], eventIdToNoteIds: {}, filters: [], highlightedDropAndProviderId: '', @@ -328,6 +335,7 @@ describe('helpers', () => { id: 'savedObject-1', isFavorite: false, isLive: false, + isSelectAllChecked: false, isLoading: false, isSaving: false, itemsPerPage: 25, @@ -337,11 +345,15 @@ describe('helpers', () => { filterQuery: null, filterQueryDraft: null, }, + loadingEventIds: [], noteIds: [], pinnedEventIds: {}, pinnedEventsSaveObject: {}, savedObjectId: 'savedObject-1', + selectedEventIds: {}, show: false, + showCheckboxes: false, + showRowRenderers: true, sort: { columnId: '@timestamp', sortDirection: 'desc', @@ -401,12 +413,14 @@ describe('helpers', () => { version: '1', dataProviders: [], description: '', + deletedEventIds: [], eventIdToNoteIds: {}, filters: [], highlightedDropAndProviderId: '', historyIds: [], isFavorite: false, isLive: false, + isSelectAllChecked: false, isLoading: false, isSaving: false, itemsPerPage: 25, @@ -416,6 +430,7 @@ describe('helpers', () => { filterQuery: null, filterQueryDraft: null, }, + loadingEventIds: [], title: '', noteIds: [], pinnedEventIds: {}, @@ -424,7 +439,10 @@ describe('helpers', () => { start: 0, end: 0, }, + selectedEventIds: {}, show: false, + showCheckboxes: false, + showRowRenderers: true, sort: { columnId: '@timestamp', sortDirection: 'desc', @@ -516,6 +534,7 @@ describe('helpers', () => { version: '1', dataProviders: [], description: '', + deletedEventIds: [], eventIdToNoteIds: {}, filters: [ { @@ -565,6 +584,7 @@ describe('helpers', () => { historyIds: [], isFavorite: false, isLive: false, + isSelectAllChecked: false, isLoading: false, isSaving: false, itemsPerPage: 25, @@ -574,6 +594,7 @@ describe('helpers', () => { filterQuery: null, filterQueryDraft: null, }, + loadingEventIds: [], title: '', noteIds: [], pinnedEventIds: {}, @@ -582,7 +603,10 @@ describe('helpers', () => { start: 0, end: 0, }, + selectedEventIds: {}, show: false, + showCheckboxes: false, + showRowRenderers: true, sort: { columnId: '@timestamp', sortDirection: 'desc', diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/actions/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/actions/index.test.tsx index 12141d0b5555..a9628ebbd183 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/actions/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/actions/index.test.tsx @@ -24,9 +24,11 @@ describe('Actions', () => { eventIsPinned={false} getNotesByIds={jest.fn()} loading={false} + loadingEventIds={[]} noteIds={[]} onEventToggled={jest.fn()} onPinClicked={jest.fn()} + onRowSelected={jest.fn()} showCheckboxes={true} showNotes={false} toggleShowNotes={jest.fn()} @@ -50,9 +52,11 @@ describe('Actions', () => { eventIsPinned={false} getNotesByIds={jest.fn()} loading={false} + loadingEventIds={[]} noteIds={[]} onEventToggled={jest.fn()} onPinClicked={jest.fn()} + onRowSelected={jest.fn()} showCheckboxes={false} showNotes={false} toggleShowNotes={jest.fn()} @@ -76,9 +80,11 @@ describe('Actions', () => { eventIsPinned={false} getNotesByIds={jest.fn()} loading={false} + loadingEventIds={[]} noteIds={[]} onEventToggled={jest.fn()} onPinClicked={jest.fn()} + onRowSelected={jest.fn()} showCheckboxes={false} showNotes={false} toggleShowNotes={jest.fn()} @@ -104,9 +110,11 @@ describe('Actions', () => { eventIsPinned={false} getNotesByIds={jest.fn()} loading={false} + loadingEventIds={[]} noteIds={[]} onEventToggled={onEventToggled} onPinClicked={jest.fn()} + onRowSelected={jest.fn()} showCheckboxes={false} showNotes={false} toggleShowNotes={jest.fn()} @@ -138,9 +146,11 @@ describe('Actions', () => { getNotesByIds={jest.fn()} isEventViewer={true} loading={false} + loadingEventIds={[]} noteIds={[]} onEventToggled={jest.fn()} onPinClicked={jest.fn()} + onRowSelected={jest.fn()} showCheckboxes={false} showNotes={false} toggleShowNotes={toggleShowNotes} @@ -166,9 +176,11 @@ describe('Actions', () => { eventIsPinned={false} getNotesByIds={jest.fn()} loading={false} + loadingEventIds={[]} noteIds={[]} onEventToggled={jest.fn()} onPinClicked={jest.fn()} + onRowSelected={jest.fn()} showCheckboxes={false} showNotes={false} toggleShowNotes={toggleShowNotes} @@ -200,9 +212,11 @@ describe('Actions', () => { getNotesByIds={jest.fn()} isEventViewer={true} loading={false} + loadingEventIds={[]} noteIds={[]} onEventToggled={jest.fn()} onPinClicked={onPinClicked} + onRowSelected={jest.fn()} showCheckboxes={false} showNotes={false} toggleShowNotes={jest.fn()} @@ -228,9 +242,11 @@ describe('Actions', () => { eventIsPinned={false} getNotesByIds={jest.fn()} loading={false} + loadingEventIds={[]} noteIds={[]} onEventToggled={jest.fn()} onPinClicked={onPinClicked} + onRowSelected={jest.fn()} showCheckboxes={false} showNotes={false} toggleShowNotes={jest.fn()} diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/actions/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/actions/index.tsx index 2de60fdf548b..54b1fb0893c8 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/actions/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/actions/index.tsx @@ -3,8 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonIcon, EuiCheckbox, EuiToolTip } from '@elastic/eui'; -import { noop } from 'lodash/fp'; +import { EuiButtonIcon, EuiCheckbox, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui'; import * as React from 'react'; import { Note } from '../../../../lib/note'; @@ -14,17 +13,33 @@ import { NotesButton } from '../../properties/helpers'; import { EventsLoading, EventsTd, EventsTdContent, EventsTdGroupActions } from '../../styles'; import { eventHasNotes, getPinTooltip } from '../helpers'; import * as i18n from '../translations'; +import { OnRowSelected } from '../../events'; +import { TimelineNonEcsData } from '../../../../graphql/types'; + +export interface TimelineActionProps { + eventId: string; + data: TimelineNonEcsData[]; +} + +export interface TimelineAction { + getAction: ({ eventId, data }: TimelineActionProps) => JSX.Element; + width: number; + id: string; +} interface Props { actionsColumnWidth: number; + additionalActions?: JSX.Element[]; associateNote: AssociateNote; checked: boolean; + onRowSelected: OnRowSelected; expanded: boolean; eventId: string; eventIsPinned: boolean; getNotesByIds: (noteIds: string[]) => Note[]; isEventViewer?: boolean; loading: boolean; + loadingEventIds: Readonly; noteIds: string[]; onEventToggled: () => void; onPinClicked: () => void; @@ -39,6 +54,7 @@ const emptyNotes: string[] = []; export const Actions = React.memo( ({ actionsColumnWidth, + additionalActions, associateNote, checked, expanded, @@ -47,9 +63,11 @@ export const Actions = React.memo( getNotesByIds, isEventViewer = false, loading = false, + loadingEventIds, noteIds, onEventToggled, onPinClicked, + onRowSelected, showCheckboxes, showNotes, toggleShowNotes, @@ -62,16 +80,27 @@ export const Actions = React.memo( {showCheckboxes && ( - + {loadingEventIds.includes(eventId) ? ( + + ) : ( + ) => { + onRowSelected({ + eventIds: [eventId], + isSelected: event.currentTarget.checked, + }); + }} + /> + )} )} + <>{additionalActions} + {loading && } @@ -137,7 +166,9 @@ export const Actions = React.memo( prevProps.eventId === nextProps.eventId && prevProps.eventIsPinned === nextProps.eventIsPinned && prevProps.loading === nextProps.loading && + prevProps.loadingEventIds === nextProps.loadingEventIds && prevProps.noteIds === nextProps.noteIds && + prevProps.onRowSelected === nextProps.onRowSelected && prevProps.showCheckboxes === nextProps.showCheckboxes && prevProps.showNotes === nextProps.showNotes ); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap index 65818b697e0b..1b66a130c355 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap @@ -8,6 +8,7 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` { actionsColumnWidth={DEFAULT_ACTIONS_COLUMN_WIDTH} browserFields={mockBrowserFields} columnHeaders={defaultHeaders} + isSelectAllChecked={false} onColumnSorted={jest.fn()} onColumnRemoved={jest.fn()} onColumnResized={jest.fn()} + onSelectAll={jest.fn} onUpdateColumns={jest.fn()} showEventsSelect={false} + showSelectAllCheckbox={false} sort={sort} timelineId={'test'} toggleColumn={jest.fn()} @@ -61,11 +64,14 @@ describe('ColumnHeaders', () => { actionsColumnWidth={DEFAULT_ACTIONS_COLUMN_WIDTH} browserFields={mockBrowserFields} columnHeaders={defaultHeaders} + isSelectAllChecked={false} onColumnSorted={jest.fn()} onColumnRemoved={jest.fn()} onColumnResized={jest.fn()} + onSelectAll={jest.fn} onUpdateColumns={jest.fn()} showEventsSelect={false} + showSelectAllCheckbox={false} sort={sort} timelineId={'test'} toggleColumn={jest.fn()} @@ -88,11 +94,14 @@ describe('ColumnHeaders', () => { actionsColumnWidth={DEFAULT_ACTIONS_COLUMN_WIDTH} browserFields={mockBrowserFields} columnHeaders={defaultHeaders} + isSelectAllChecked={false} onColumnSorted={jest.fn()} onColumnRemoved={jest.fn()} onColumnResized={jest.fn()} + onSelectAll={jest.fn} onUpdateColumns={jest.fn()} showEventsSelect={false} + showSelectAllCheckbox={false} sort={sort} timelineId={'test'} toggleColumn={jest.fn()} @@ -117,11 +126,14 @@ describe('ColumnHeaders', () => { actionsColumnWidth={DEFAULT_ACTIONS_COLUMN_WIDTH} browserFields={mockBrowserFields} columnHeaders={defaultHeaders} + isSelectAllChecked={false} onColumnSorted={jest.fn()} onColumnRemoved={jest.fn()} onColumnResized={jest.fn()} + onSelectAll={jest.fn} onUpdateColumns={jest.fn()} showEventsSelect={false} + showSelectAllCheckbox={false} sort={sort} timelineId={'test'} toggleColumn={jest.fn()} diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.tsx index f6562b4db064..95a7ae52b0f2 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiCheckbox } from '@elastic/eui'; import { noop } from 'lodash/fp'; import * as React from 'react'; import { Draggable, Droppable } from 'react-beautiful-dnd'; @@ -11,9 +12,9 @@ import { Draggable, Droppable } from 'react-beautiful-dnd'; import { BrowserFields } from '../../../../containers/source'; import { DragEffects } from '../../../drag_and_drop/draggable_wrapper'; import { + DRAG_TYPE_FIELD, droppableTimelineColumnsPrefix, getDraggableFieldId, - DRAG_TYPE_FIELD, } from '../../../drag_and_drop/helpers'; import { DraggableFieldBadge } from '../../../draggables/field_badge'; import { StatefulFieldsBrowser } from '../../../fields_browser'; @@ -24,6 +25,7 @@ import { OnColumnResized, OnColumnSorted, OnFilterChange, + OnSelectAll, OnUpdateColumns, } from '../../events'; import { @@ -44,12 +46,15 @@ interface Props { browserFields: BrowserFields; columnHeaders: ColumnHeader[]; isEventViewer?: boolean; + isSelectAllChecked: boolean; onColumnRemoved: OnColumnRemoved; onColumnResized: OnColumnResized; onColumnSorted: OnColumnSorted; onFilterChange?: OnFilterChange; + onSelectAll: OnSelectAll; onUpdateColumns: OnUpdateColumns; showEventsSelect: boolean; + showSelectAllCheckbox: boolean; sort: Sort; timelineId: string; toggleColumn: (column: ColumnHeader) => void; @@ -61,12 +66,15 @@ export const ColumnHeadersComponent = ({ browserFields, columnHeaders, isEventViewer = false, + isSelectAllChecked, onColumnRemoved, onColumnResized, onColumnSorted, + onSelectAll, onUpdateColumns, onFilterChange = noop, showEventsSelect, + showSelectAllCheckbox, sort, timelineId, toggleColumn, @@ -78,6 +86,7 @@ export const ColumnHeadersComponent = ({ {showEventsSelect && ( @@ -88,8 +97,23 @@ export const ColumnHeadersComponent = ({ )} + {showSelectAllCheckbox && ( + + + ) => { + onSelectAll({ isSelected: event.currentTarget.checked }); + }} + /> + + + )} + - + ; onColumnResized: OnColumnResized; onEventToggled: () => void; onPinEvent: OnPinEvent; + onRowSelected: OnRowSelected; onUnPinEvent: OnUnPinEvent; + selectedEventIds: Readonly>; + showCheckboxes: boolean; showNotes: boolean; timelineId: string; toggleShowNotes: () => void; @@ -60,10 +64,14 @@ export const EventColumnView = React.memo( isEventPinned = false, isEventViewer = false, loading, + loadingEventIds, onColumnResized, onEventToggled, onPinEvent, + onRowSelected, onUnPinEvent, + selectedEventIds, + showCheckboxes, showNotes, timelineId, toggleShowNotes, @@ -71,12 +79,24 @@ export const EventColumnView = React.memo( }) => { const timelineTypeContext = useTimelineTypeContext(); + const additionalActions = useMemo(() => { + return ( + timelineTypeContext.timelineActions?.map(action => ( + + {action.getAction({ eventId: id, data })} + + )) ?? [] + ); + }, [data, timelineTypeContext.timelineActions]); + return ( ( getNotesByIds={getNotesByIds} isEventViewer={isEventViewer} loading={loading} + loadingEventIds={loadingEventIds} noteIds={eventIdToNoteIds[id] || emptyNotes} onEventToggled={onEventToggled} onPinClicked={getPinOnClick({ @@ -93,7 +114,7 @@ export const EventColumnView = React.memo( onUnPinEvent, isEventPinned, })} - showCheckboxes={timelineTypeContext.showCheckboxes} + showCheckboxes={showCheckboxes} showNotes={showNotes} toggleShowNotes={toggleShowNotes} updateNote={updateNote} @@ -120,7 +141,11 @@ export const EventColumnView = React.memo( prevProps.eventIdToNoteIds === nextProps.eventIdToNoteIds && prevProps.expanded === nextProps.expanded && prevProps.loading === nextProps.loading && + prevProps.loadingEventIds === nextProps.loadingEventIds && prevProps.isEventPinned === nextProps.isEventPinned && + prevProps.onRowSelected === nextProps.onRowSelected && + prevProps.selectedEventIds === nextProps.selectedEventIds && + prevProps.showCheckboxes === nextProps.showCheckboxes && prevProps.showNotes === nextProps.showNotes && prevProps.timelineId === nextProps.timelineId ); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/events/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/events/index.tsx index 34281219bcc0..9361a46dddff 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/events/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/events/index.tsx @@ -7,11 +7,17 @@ import React from 'react'; import { BrowserFields } from '../../../../containers/source'; -import { TimelineItem } from '../../../../graphql/types'; +import { TimelineItem, TimelineNonEcsData } from '../../../../graphql/types'; import { maxDelay } from '../../../../lib/helpers/scheduler'; import { Note } from '../../../../lib/note'; import { AddNoteToEvent, UpdateNote } from '../../../notes/helpers'; -import { OnColumnResized, OnPinEvent, OnUnPinEvent, OnUpdateColumns } from '../../events'; +import { + OnColumnResized, + OnPinEvent, + OnRowSelected, + OnUnPinEvent, + OnUpdateColumns, +} from '../../events'; import { EventsTbody } from '../../styles'; import { ColumnHeader } from '../column_headers/column_header'; import { ColumnRenderer } from '../renderers/column_renderer'; @@ -30,12 +36,16 @@ interface Props { getNotesByIds: (noteIds: string[]) => Note[]; id: string; isEventViewer?: boolean; + loadingEventIds: Readonly; onColumnResized: OnColumnResized; onPinEvent: OnPinEvent; + onRowSelected: OnRowSelected; onUpdateColumns: OnUpdateColumns; onUnPinEvent: OnUnPinEvent; pinnedEventIds: Readonly>; rowRenderers: RowRenderer[]; + selectedEventIds: Readonly>; + showCheckboxes: boolean; toggleColumn: (column: ColumnHeader) => void; updateNote: UpdateNote; } @@ -55,12 +65,16 @@ export const Events = React.memo( getNotesByIds, id, isEventViewer = false, + loadingEventIds, onColumnResized, onPinEvent, + onRowSelected, onUpdateColumns, onUnPinEvent, pinnedEventIds, rowRenderers, + selectedEventIds, + showCheckboxes, toggleColumn, updateNote, }) => ( @@ -78,12 +92,16 @@ export const Events = React.memo( isEventPinned={eventIsPinned({ eventId: event._id, pinnedEventIds })} isEventViewer={isEventViewer} key={event._id} + loadingEventIds={loadingEventIds} maxDelay={maxDelay(i)} onColumnResized={onColumnResized} onPinEvent={onPinEvent} + onRowSelected={onRowSelected} onUnPinEvent={onUnPinEvent} onUpdateColumns={onUpdateColumns} rowRenderers={rowRenderers} + selectedEventIds={selectedEventIds} + showCheckboxes={showCheckboxes} timelineId={id} toggleColumn={toggleColumn} updateNote={updateNote} diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event.tsx index b3ef4b7b3946..baa5c35880d6 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event.tsx @@ -10,12 +10,18 @@ import VisibilitySensor from 'react-visibility-sensor'; import { BrowserFields } from '../../../../containers/source'; import { TimelineDetailsComponentQuery } from '../../../../containers/timeline/details'; -import { TimelineItem, DetailItem } from '../../../../graphql/types'; +import { TimelineItem, DetailItem, TimelineNonEcsData } from '../../../../graphql/types'; import { requestIdleCallbackViaScheduler } from '../../../../lib/helpers/scheduler'; import { Note } from '../../../../lib/note'; import { AddNoteToEvent, UpdateNote } from '../../../notes/helpers'; import { SkeletonRow } from '../../../skeleton_row'; -import { OnColumnResized, OnPinEvent, OnUnPinEvent, OnUpdateColumns } from '../../events'; +import { + OnColumnResized, + OnPinEvent, + OnRowSelected, + OnUnPinEvent, + OnUpdateColumns, +} from '../../events'; import { ExpandableEvent } from '../../expandable_event'; import { STATEFUL_EVENT_CSS_CLASS_NAME } from '../../helpers'; import { EventsTrGroup, EventsTrSupplement, OFFSET_SCROLLBAR } from '../../styles'; @@ -36,13 +42,17 @@ interface Props { eventIdToNoteIds: Readonly>; getNotesByIds: (noteIds: string[]) => Note[]; isEventViewer?: boolean; + loadingEventIds: Readonly; maxDelay?: number; onColumnResized: OnColumnResized; onPinEvent: OnPinEvent; + onRowSelected: OnRowSelected; onUnPinEvent: OnUnPinEvent; onUpdateColumns: OnUpdateColumns; isEventPinned: boolean; rowRenderers: RowRenderer[]; + selectedEventIds: Readonly>; + showCheckboxes: boolean; timelineId: string; toggleColumn: (column: ColumnHeader) => void; updateNote: UpdateNote; @@ -110,12 +120,16 @@ export const StatefulEvent = React.memo( getNotesByIds, isEventViewer = false, isEventPinned = false, + loadingEventIds, maxDelay = 0, onColumnResized, onPinEvent, + onRowSelected, onUnPinEvent, onUpdateColumns, rowRenderers, + selectedEventIds, + showCheckboxes, timelineId, toggleColumn, updateNote, @@ -224,11 +238,15 @@ export const StatefulEvent = React.memo( isEventPinned={isEventPinned} isEventViewer={isEventViewer} loading={loading} + loadingEventIds={loadingEventIds} onColumnResized={onColumnResized} onPinEvent={onPinEvent} + onRowSelected={onRowSelected} onToggleExpanded={onToggleExpanded} onToggleShowNotes={onToggleShowNotes} onUnPinEvent={onUnPinEvent} + selectedEventIds={selectedEventIds} + showCheckboxes={showCheckboxes} showNotes={!!showNotes[event._id]} timelineId={timelineId} updateNote={updateNote} diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event_child.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event_child.tsx index 668139349a37..9ea1bbb1e843 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event_child.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event_child.tsx @@ -11,7 +11,7 @@ import { TimelineNonEcsData } from '../../../../graphql/types'; import { Note } from '../../../../lib/note'; import { AddNoteToEvent, UpdateNote } from '../../../notes/helpers'; import { NoteCards } from '../../../notes/note_cards'; -import { OnPinEvent, OnColumnResized, OnUnPinEvent } from '../../events'; +import { OnPinEvent, OnColumnResized, OnUnPinEvent, OnRowSelected } from '../../events'; import { EventsTrSupplement, OFFSET_SCROLLBAR } from '../../styles'; import { useTimelineWidthContext } from '../../timeline_context'; import { ColumnHeader } from '../column_headers/column_header'; @@ -31,8 +31,12 @@ interface Props { isEventViewer?: boolean; isEventPinned: boolean; loading: boolean; + loadingEventIds: Readonly; onColumnResized: OnColumnResized; + onRowSelected: OnRowSelected; onUnPinEvent: OnUnPinEvent; + selectedEventIds: Readonly>; + showCheckboxes: boolean; showNotes: boolean; timelineId: string; updateNote: UpdateNote; @@ -62,9 +66,13 @@ export const StatefulEventChild = React.memo( isEventViewer = false, isEventPinned = false, loading, + loadingEventIds, onColumnResized, + onRowSelected, onToggleExpanded, onUnPinEvent, + selectedEventIds, + showCheckboxes, showNotes, timelineId, onToggleShowNotes, @@ -90,10 +98,14 @@ export const StatefulEventChild = React.memo( isEventPinned={isEventPinned} isEventViewer={isEventViewer} loading={loading} + loadingEventIds={loadingEventIds} onColumnResized={onColumnResized} onEventToggled={onToggleExpanded} onPinEvent={onPinEvent} + onRowSelected={onRowSelected} onUnPinEvent={onUnPinEvent} + selectedEventIds={selectedEventIds} + showCheckboxes={showCheckboxes} showNotes={showNotes} timelineId={timelineId} toggleShowNotes={onToggleShowNotes} diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/helpers.ts b/x-pack/legacy/plugins/siem/public/components/timeline/body/helpers.ts index 5fdc2fddfbfb..c11b884f8a80 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/helpers.ts @@ -6,7 +6,7 @@ import { get, isEmpty, noop } from 'lodash/fp'; import { BrowserFields } from '../../../containers/source'; -import { Ecs } from '../../../graphql/types'; +import { Ecs, TimelineItem, TimelineNonEcsData } from '../../../graphql/types'; import { OnPinEvent, OnUnPinEvent } from '../events'; import { ColumnHeader } from './column_headers/column_header'; import * as i18n from './translations'; @@ -95,6 +95,34 @@ export const getColumnHeaders = ( }; /** Returns the (fixed) width of the Actions column */ -export const getActionsColumnWidth = (isEventViewer: boolean, showCheckboxes = false): number => +export const getActionsColumnWidth = ( + isEventViewer: boolean, + showCheckboxes = false, + additionalActionWidth = 0 +): number => (showCheckboxes ? SHOW_CHECK_BOXES_COLUMN_WIDTH : 0) + - (isEventViewer ? EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH : DEFAULT_ACTIONS_COLUMN_WIDTH); + (isEventViewer ? EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH : DEFAULT_ACTIONS_COLUMN_WIDTH) + + additionalActionWidth; + +/** + * Creates mapping of eventID -> fieldData for given fieldsToKeep. Used to store additional field + * data necessary for custom timeline actions in conjunction with selection state + * @param timelineData + * @param eventIds + * @param fieldsToKeep + */ +export const getEventIdToDataMapping = ( + timelineData: TimelineItem[], + eventIds: string[], + fieldsToKeep: string[] +): Record => { + return timelineData.reduce((acc, v) => { + const fvm = eventIds.includes(v._id) + ? { [v._id]: v.data.filter(ti => fieldsToKeep.includes(ti.field)) } + : {}; + return { + ...acc, + ...fvm, + }; + }, {}); +}; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/index.test.tsx index a4ed5571bb0d..d3eaedb3ef23 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/index.test.tsx @@ -55,18 +55,24 @@ describe('Body', () => { eventIdToNoteIds={{}} height={testBodyHeight} id={'timeline-test'} + isSelectAllChecked={false} getNotesByIds={mockGetNotesByIds} + loadingEventIds={[]} onColumnRemoved={jest.fn()} onColumnResized={jest.fn()} onColumnSorted={jest.fn()} onFilterChange={jest.fn()} onPinEvent={jest.fn()} + onRowSelected={jest.fn()} + onSelectAll={jest.fn()} onUnPinEvent={jest.fn()} onUpdateColumns={jest.fn()} pinnedEventIds={{}} range={'1 Day'} rowRenderers={rowRenderers} + selectedEventIds={{}} sort={mockSort} + showCheckboxes={false} toggleColumn={jest.fn()} updateNote={jest.fn()} /> @@ -93,18 +99,24 @@ describe('Body', () => { eventIdToNoteIds={{}} height={testBodyHeight} id={'timeline-test'} + isSelectAllChecked={false} getNotesByIds={mockGetNotesByIds} + loadingEventIds={[]} onColumnRemoved={jest.fn()} onColumnResized={jest.fn()} onColumnSorted={jest.fn()} onFilterChange={jest.fn()} onPinEvent={jest.fn()} + onRowSelected={jest.fn()} + onSelectAll={jest.fn()} onUnPinEvent={jest.fn()} onUpdateColumns={jest.fn()} pinnedEventIds={{}} range={'1 Day'} rowRenderers={rowRenderers} + selectedEventIds={{}} sort={mockSort} + showCheckboxes={false} toggleColumn={jest.fn()} updateNote={jest.fn()} /> @@ -131,18 +143,24 @@ describe('Body', () => { eventIdToNoteIds={{}} height={testBodyHeight} id={'timeline-test'} + isSelectAllChecked={false} getNotesByIds={mockGetNotesByIds} + loadingEventIds={[]} onColumnRemoved={jest.fn()} onColumnResized={jest.fn()} onColumnSorted={jest.fn()} onFilterChange={jest.fn()} onPinEvent={jest.fn()} + onRowSelected={jest.fn()} + onSelectAll={jest.fn()} onUnPinEvent={jest.fn()} onUpdateColumns={jest.fn()} pinnedEventIds={{}} range={'1 Day'} rowRenderers={rowRenderers} + selectedEventIds={{}} sort={mockSort} + showCheckboxes={false} toggleColumn={jest.fn()} updateNote={jest.fn()} /> @@ -171,18 +189,24 @@ describe('Body', () => { eventIdToNoteIds={{}} height={testBodyHeight} id={'timeline-test'} + isSelectAllChecked={false} getNotesByIds={mockGetNotesByIds} + loadingEventIds={[]} onColumnRemoved={jest.fn()} onColumnResized={jest.fn()} onColumnSorted={jest.fn()} onFilterChange={jest.fn()} onPinEvent={jest.fn()} + onRowSelected={jest.fn()} + onSelectAll={jest.fn()} onUnPinEvent={jest.fn()} onUpdateColumns={jest.fn()} pinnedEventIds={{}} range={'1 Day'} rowRenderers={rowRenderers} + selectedEventIds={{}} sort={mockSort} + showCheckboxes={false} toggleColumn={jest.fn()} updateNote={jest.fn()} /> @@ -258,18 +282,24 @@ describe('Body', () => { eventIdToNoteIds={{}} height={testBodyHeight} id={'timeline-test'} + isSelectAllChecked={false} getNotesByIds={mockGetNotesByIds} + loadingEventIds={[]} onColumnRemoved={jest.fn()} onColumnResized={jest.fn()} onColumnSorted={jest.fn()} onFilterChange={jest.fn()} onPinEvent={dispatchOnPinEvent} + onRowSelected={jest.fn()} + onSelectAll={jest.fn()} onUnPinEvent={jest.fn()} onUpdateColumns={jest.fn()} pinnedEventIds={{}} range={'1 Day'} rowRenderers={rowRenderers} + selectedEventIds={{}} sort={mockSort} + showCheckboxes={false} toggleColumn={jest.fn()} updateNote={jest.fn()} /> @@ -298,18 +328,24 @@ describe('Body', () => { eventIdToNoteIds={{}} height={testBodyHeight} id={'timeline-test'} + isSelectAllChecked={false} getNotesByIds={mockGetNotesByIds} + loadingEventIds={[]} onColumnRemoved={jest.fn()} onColumnResized={jest.fn()} onColumnSorted={jest.fn()} onFilterChange={jest.fn()} onPinEvent={dispatchOnPinEvent} + onRowSelected={jest.fn()} + onSelectAll={jest.fn()} onUnPinEvent={jest.fn()} onUpdateColumns={jest.fn()} pinnedEventIds={{}} range={'1 Day'} rowRenderers={rowRenderers} + selectedEventIds={{}} sort={mockSort} + showCheckboxes={false} toggleColumn={jest.fn()} updateNote={jest.fn()} /> diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/index.tsx index 0aed68a0e4ad..23406f1b5f35 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/index.tsx @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as React from 'react'; +import React, { useMemo } from 'react'; import { BrowserFields } from '../../../containers/source'; -import { TimelineItem } from '../../../graphql/types'; +import { TimelineItem, TimelineNonEcsData } from '../../../graphql/types'; import { Note } from '../../../lib/note'; import { AddNoteToEvent, UpdateNote } from '../../notes/helpers'; import { @@ -16,6 +16,8 @@ import { OnColumnSorted, OnFilterChange, OnPinEvent, + OnRowSelected, + OnSelectAll, OnUnPinEvent, OnUpdateColumns, } from '../events'; @@ -39,10 +41,14 @@ export interface BodyProps { height: number; id: string; isEventViewer?: boolean; + isSelectAllChecked: boolean; eventIdToNoteIds: Readonly>; + loadingEventIds: Readonly; onColumnRemoved: OnColumnRemoved; onColumnResized: OnColumnResized; onColumnSorted: OnColumnSorted; + onRowSelected: OnRowSelected; + onSelectAll: OnSelectAll; onFilterChange: OnFilterChange; onPinEvent: OnPinEvent; onUpdateColumns: OnUpdateColumns; @@ -50,6 +56,8 @@ export interface BodyProps { pinnedEventIds: Readonly>; range: string; rowRenderers: RowRenderer[]; + selectedEventIds: Readonly>; + showCheckboxes: boolean; sort: Sort; toggleColumn: (column: ColumnHeader) => void; updateNote: UpdateNote; @@ -68,24 +76,38 @@ export const Body = React.memo( height, id, isEventViewer = false, + isSelectAllChecked, + loadingEventIds, onColumnRemoved, onColumnResized, onColumnSorted, + onRowSelected, + onSelectAll, onFilterChange, onPinEvent, onUpdateColumns, onUnPinEvent, pinnedEventIds, rowRenderers, + selectedEventIds, + showCheckboxes, sort, toggleColumn, updateNote, }) => { const timelineTypeContext = useTimelineTypeContext(); + const additionalActionWidth = + timelineTypeContext.timelineActions?.reduce((acc, v) => acc + v.width, 0) ?? 0; - const columnWidths = columnHeaders.reduce( - (totalWidth, header) => totalWidth + header.width, - getActionsColumnWidth(isEventViewer, timelineTypeContext.showCheckboxes) + const actionsColumnWidth = useMemo( + () => getActionsColumnWidth(isEventViewer, showCheckboxes, additionalActionWidth), + [isEventViewer, showCheckboxes, additionalActionWidth] + ); + + const columnWidths = useMemo( + () => + columnHeaders.reduce((totalWidth, header) => totalWidth + header.width, actionsColumnWidth), + [actionsColumnWidth, columnHeaders] ); return ( @@ -97,29 +119,26 @@ export const Body = React.memo( style={{ minWidth: columnWidths + 'px' }} > ( getNotesByIds={getNotesByIds} id={id} isEventViewer={isEventViewer} + loadingEventIds={loadingEventIds} onColumnResized={onColumnResized} onPinEvent={onPinEvent} + onRowSelected={onRowSelected} onUpdateColumns={onUpdateColumns} onUnPinEvent={onUnPinEvent} pinnedEventIds={pinnedEventIds} rowRenderers={rowRenderers} + selectedEventIds={selectedEventIds} + showCheckboxes={showCheckboxes} toggleColumn={toggleColumn} updateNote={updateNote} /> diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/stateful_body.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/stateful_body.tsx index cd5a677dbb39..8fe6759acf52 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/stateful_body.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/stateful_body.tsx @@ -6,12 +6,12 @@ import { noop } from 'lodash/fp'; import memoizeOne from 'memoize-one'; -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { connect } from 'react-redux'; import { ActionCreator } from 'typescript-fsa'; import { BrowserFields } from '../../../containers/source'; -import { TimelineItem } from '../../../graphql/types'; +import { TimelineItem, TimelineNonEcsData } from '../../../graphql/types'; import { Note } from '../../../lib/note'; import { appModel, appSelectors, State, timelineSelectors } from '../../../store'; import { AddNoteToEvent, UpdateNote } from '../../notes/helpers'; @@ -20,12 +20,14 @@ import { OnColumnResized, OnColumnSorted, OnPinEvent, + OnRowSelected, + OnSelectAll, OnUnPinEvent, OnUpdateColumns, } from '../events'; import { ColumnHeader } from './column_headers/column_header'; -import { getColumnHeaders } from './helpers'; +import { getColumnHeaders, getEventIdToDataMapping } from './helpers'; import { Body } from './index'; import { columnRenderers, rowRenderers } from './renderers'; import { Sort } from './sort'; @@ -47,9 +49,14 @@ interface OwnProps { interface ReduxProps { columnHeaders: ColumnHeader[]; eventIdToNoteIds: Readonly>; + isSelectAllChecked: boolean; + loadingEventIds: Readonly; notesById: appModel.NotesById; pinnedEventIds: Readonly>; range?: string; + selectedEventIds: Readonly>; + showCheckboxes: boolean; + showRowRenderers: boolean; } interface DispatchProps { @@ -59,6 +66,9 @@ interface DispatchProps { columnId: string; delta: number; }>; + clearSelected?: ActionCreator<{ + id: string; + }>; pinEvent?: ActionCreator<{ id: string; eventId: string; @@ -67,6 +77,12 @@ interface DispatchProps { id: string; columnId: string; }>; + setSelected?: ActionCreator<{ + id: string; + eventIds: Record; + isSelected: boolean; + isSelectAllChecked: boolean; + }>; unPinEvent?: ActionCreator<{ id: string; eventId: string; @@ -97,11 +113,18 @@ const StatefulBodyComponent = React.memo( height, id, isEventViewer = false, + isSelectAllChecked, + loadingEventIds, notesById, pinEvent, pinnedEventIds, range, removeColumn, + selectedEventIds, + setSelected, + clearSelected, + showCheckboxes, + showRowRenderers, sort, toggleColumn, unPinEvent, @@ -122,6 +145,36 @@ const StatefulBodyComponent = React.memo( [id] ); + const onRowSelected: OnRowSelected = useCallback( + ({ eventIds, isSelected }: { eventIds: string[]; isSelected: boolean }) => { + setSelected!({ + id, + eventIds: getEventIdToDataMapping(data, eventIds, timelineTypeContext.queryFields ?? []), + isSelected, + isSelectAllChecked: + isSelected && Object.keys(selectedEventIds).length + 1 === data.length, + }); + }, + [id, data, selectedEventIds, timelineTypeContext.queryFields] + ); + + const onSelectAll: OnSelectAll = useCallback( + ({ isSelected }: { isSelected: boolean }) => + isSelected + ? setSelected!({ + id, + eventIds: getEventIdToDataMapping( + data, + data.map(event => event._id), + timelineTypeContext.queryFields ?? [] + ), + isSelected, + isSelectAllChecked: isSelected, + }) + : clearSelected!({ id }), + [id, data, timelineTypeContext.queryFields] + ); + const onColumnSorted: OnColumnSorted = useCallback( sorted => { updateSort!({ id, sort: sorted }); @@ -150,6 +203,13 @@ const StatefulBodyComponent = React.memo( [id] ); + // Sync to timelineTypeContext.selectAll so parent components can select all events + useEffect(() => { + if (timelineTypeContext.selectAll) { + onSelectAll({ isSelected: true }); + } + }, [timelineTypeContext.selectAll]); // onSelectAll dependency not necessary + return ( ( height={height} id={id} isEventViewer={isEventViewer} + isSelectAllChecked={isSelectAllChecked} + loadingEventIds={loadingEventIds} onColumnRemoved={onColumnRemoved} onColumnResized={onColumnResized} onColumnSorted={onColumnSorted} + onRowSelected={onRowSelected} + onSelectAll={onSelectAll} onFilterChange={noop} // TODO: this is the callback for column filters, which is out scope for this phase of delivery onPinEvent={onPinEvent} onUnPinEvent={onUnPinEvent} onUpdateColumns={onUpdateColumns} pinnedEventIds={pinnedEventIds} range={range!} - rowRenderers={timelineTypeContext.showRowRenderers ? rowRenderers : [plainRowRenderer]} + rowRenderers={showRowRenderers ? rowRenderers : [plainRowRenderer]} + selectedEventIds={selectedEventIds} + showCheckboxes={showCheckboxes} sort={sort} toggleColumn={toggleColumn} updateNote={onUpdateNote} @@ -188,7 +254,12 @@ const StatefulBodyComponent = React.memo( prevProps.height === nextProps.height && prevProps.id === nextProps.id && prevProps.isEventViewer === nextProps.isEventViewer && + prevProps.isSelectAllChecked === nextProps.isSelectAllChecked && + prevProps.loadingEventIds === nextProps.loadingEventIds && prevProps.pinnedEventIds === nextProps.pinnedEventIds && + prevProps.selectedEventIds === nextProps.selectedEventIds && + prevProps.showCheckboxes === nextProps.showCheckboxes && + prevProps.showRowRenderers === nextProps.showRowRenderers && prevProps.range === nextProps.range && prevProps.sort === nextProps.sort ); @@ -207,14 +278,28 @@ const makeMapStateToProps = () => { const getNotesByIds = appSelectors.notesByIdsSelector(); const mapStateToProps = (state: State, { browserFields, id }: OwnProps) => { const timeline: TimelineModel = getTimeline(state, id) ?? timelineDefaults; - const { columns, eventIdToNoteIds, pinnedEventIds } = timeline; + const { + columns, + eventIdToNoteIds, + isSelectAllChecked, + loadingEventIds, + pinnedEventIds, + selectedEventIds, + showCheckboxes, + showRowRenderers, + } = timeline; return { columnHeaders: memoizedColumnHeaders(columns, browserFields), eventIdToNoteIds, + isSelectAllChecked, + loadingEventIds, notesById: getNotesByIds(state), id, pinnedEventIds, + selectedEventIds, + showCheckboxes, + showRowRenderers, }; }; return mapStateToProps; @@ -223,9 +308,11 @@ const makeMapStateToProps = () => { export const StatefulBody = connect(makeMapStateToProps, { addNoteToEvent: timelineActions.addNoteToEvent, applyDeltaToColumnWidth: timelineActions.applyDeltaToColumnWidth, + clearSelected: timelineActions.clearSelected, pinEvent: timelineActions.pinEvent, removeColumn: timelineActions.removeColumn, removeProvider: timelineActions.removeProvider, + setSelected: timelineActions.setSelected, unPinEvent: timelineActions.unPinEvent, updateColumns: timelineActions.updateColumns, updateNote: appActions.updateNote, diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/events.ts b/x-pack/legacy/plugins/siem/public/components/timeline/events.ts index 253be41a009c..b54ed52bb9f1 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/events.ts +++ b/x-pack/legacy/plugins/siem/public/components/timeline/events.ts @@ -72,6 +72,18 @@ export type OnChangeDroppableAndProvider = (providerId: string) => void; /** Invoked when a user pins an event */ export type OnPinEvent = (eventId: string) => void; +/** Invoked when a user checks/un-checks a row */ +export type OnRowSelected = ({ + eventIds, + isSelected, +}: { + eventIds: string[]; + isSelected: boolean; +}) => void; + +/** Invoked when a user checks/un-checks the select all checkbox */ +export type OnSelectAll = ({ isSelected }: { isSelected: boolean }) => void; + /** Invoked when columns are updated */ export type OnUpdateColumns = (columns: ColumnHeader[]) => void; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.tsx index 978386e61180..1fcc4382c179 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.tsx @@ -211,6 +211,7 @@ export const FooterComponent = ({ const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [paginationLoading, setPaginationLoading] = useState(false); const [updatedAt, setUpdatedAt] = useState(null); + const timelineTypeContext = useTimelineTypeContext(); const loadMore = useCallback(() => { setPaginationLoading(true); @@ -239,7 +240,7 @@ export const FooterComponent = ({ data-test-subj="LoadingPanelTimeline" height="35px" showBorder={false} - text={isEventViewer ? `${i18n.LOADING_EVENTS}...` : `${i18n.LOADING_TIMELINE_DATA}...`} + text={`${timelineTypeContext.loadingText ?? i18n.LOADING_TIMELINE_DATA}...`} width="100%" /> diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/footer/translations.ts b/x-pack/legacy/plugins/siem/public/components/timeline/footer/translations.ts index e7f5ba4026ac..886866ce1b0c 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/footer/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/timeline/footer/translations.ts @@ -6,10 +6,6 @@ import { i18n } from '@kbn/i18n'; -export const LOADING_EVENTS = i18n.translate('xpack.siem.footer.loadingEventsData', { - defaultMessage: 'Loading Events', -}); - export const LOADING_TIMELINE_DATA = i18n.translate('xpack.siem.footer.loadingTimelineData', { defaultMessage: 'Loading Timeline data', }); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/styles.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/styles.tsx index a138a94e5709..db5d27626fc6 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/styles.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/styles.tsx @@ -87,10 +87,10 @@ EventsTrHeader.displayName = 'EventsTrHeader'; export const EventsThGroupActions = styled.div.attrs(({ className }) => ({ className: `siemEventsTable__thGroupActions ${className}`, -}))<{ actionsColumnWidth: number }>` +}))<{ actionsColumnWidth: number; justifyContent: string }>` display: flex; flex: 0 0 ${({ actionsColumnWidth }) => actionsColumnWidth + 'px'}; - justify-content: space-between; + justify-content: ${({ justifyContent }) => justifyContent}; min-width: 0; `; EventsThGroupActions.displayName = 'EventsThGroupActions'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/timeline_context.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/timeline_context.tsx index 584fe03d2149..d3251e1d331e 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/timeline_context.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/timeline_context.tsx @@ -5,6 +5,7 @@ */ import React, { createContext, memo, useContext, useEffect, useState } from 'react'; +import { TimelineAction } from './body/actions'; const initTimelineContext = false; export const TimelineContext = createContext(initTimelineContext); @@ -17,15 +18,19 @@ export const useTimelineWidthContext = () => useContext(TimelineWidthContext); export interface TimelineTypeContextProps { documentType?: string; footerText?: string; - showCheckboxes: boolean; - showRowRenderers: boolean; + loadingText?: string; + queryFields?: string[]; + selectAll?: boolean; + timelineActions?: TimelineAction[]; title?: string; } const initTimelineType: TimelineTypeContextProps = { documentType: undefined, footerText: undefined, - showCheckboxes: false, - showRowRenderers: true, + loadingText: undefined, + queryFields: [], + selectAll: false, + timelineActions: [], title: undefined, }; export const TimelineTypeContext = createContext(initTimelineType); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/api.ts new file mode 100644 index 000000000000..49dada410c5c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/api.ts @@ -0,0 +1,40 @@ +/* + * 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 chrome from 'ui/chrome'; +import { UpdateSignalStatusProps } from './types'; +import { throwIfNotOk } from '../../../hooks/api/api'; +import { DETECTION_ENGINE_SIGNALS_STATUS_URL } from '../../../../common/constants'; + +/** + * Update signal status by query + * + * @param query of signals to update + * @param status to update to ('open' / 'closed') + * @param kbnVersion current Kibana Version to use for headers + * @param signal to cancel request + */ +export const updateSignalStatus = async ({ + query, + status, + kbnVersion, + signal, +}: UpdateSignalStatusProps): Promise => { + const response = await fetch(`${chrome.getBasePath()}${DETECTION_ENGINE_SIGNALS_STATUS_URL}`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-version': kbnVersion, + 'kbn-xsrf': kbnVersion, + }, + body: JSON.stringify({ status, ...query }), + signal, + }); + + await throwIfNotOk(response); + return response.json(); +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/types.ts new file mode 100644 index 000000000000..4f316a7caaac --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/types.ts @@ -0,0 +1,12 @@ +/* + * 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 interface UpdateSignalStatusProps { + query: object; + status: 'open' | 'closed'; + kbnVersion: string; + signal?: AbortSignal; // TODO: implement cancelling +} diff --git a/x-pack/legacy/plugins/siem/public/mock/global_state.ts b/x-pack/legacy/plugins/siem/public/mock/global_state.ts index 750d5292950b..31e203d08032 100644 --- a/x-pack/legacy/plugins/siem/public/mock/global_state.ts +++ b/x-pack/legacy/plugins/siem/public/mock/global_state.ts @@ -183,6 +183,7 @@ export const mockGlobalState: State = { }, timelineById: { test: { + deletedEventIds: [], id: 'test', savedObjectId: null, columns: defaultHeaders, @@ -194,16 +195,21 @@ export const mockGlobalState: State = { historyIds: [], isFavorite: false, isLive: false, + isSelectAllChecked: false, isLoading: false, kqlMode: 'filter', kqlQuery: { filterQuery: null, filterQueryDraft: null }, + loadingEventIds: [], title: '', noteIds: [], dateRange: { start: 0, end: 0, }, + selectedEventIds: {}, show: false, + showRowRenderers: true, + showCheckboxes: false, pinnedEventIds: {}, pinnedEventsSaveObject: {}, itemsPerPageOptions: [5, 10, 20], diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx index e1373c8b18bb..d17685aa326c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx @@ -19,6 +19,7 @@ import { SpyRoute } from '../../utils/route/spy_routes'; import { DetectionEngineEmptyPage } from './detection_engine_empty_page'; import * as i18n from './translations'; import { SignalsTable } from './signals'; +import { GlobalTime } from '../../containers/global_time'; export const DetectionEngineComponent = React.memo(() => { const sampleChartOptions = [ @@ -65,8 +66,7 @@ export const DetectionEngineComponent = React.memo(() => { - - + {({ to, from }) => } ) : ( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/actions.tsx new file mode 100644 index 000000000000..0823024c078d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/actions.tsx @@ -0,0 +1,81 @@ +/* + * 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 moment from 'moment'; + +import { updateSignalStatus } from '../../../containers/detection_engine/signals/api'; +import { SendSignalsToTimelineActionProps, UpdateSignalStatusActionProps } from './types'; +import { TimelineNonEcsData } from '../../../graphql/types'; + +export const getUpdateSignalsQuery = (eventIds: Readonly) => { + return { + query: { + bool: { + filter: { + terms: { + _id: [...eventIds], + }, + }, + }, + }, + }; +}; + +export const getFilterAndRuleBounds = ( + data: TimelineNonEcsData[][] +): [string[], number, number] => { + const stringFilter = data?.[0].filter(d => d.field === 'signal.rule.filters')?.[0]?.value ?? []; + + const eventTimes = data + .flatMap(signal => signal.filter(d => d.field === 'signal.original_time')?.[0]?.value ?? []) + .map(d => moment(d)); + + return [stringFilter, moment.min(eventTimes).valueOf(), moment.max(eventTimes).valueOf()]; +}; + +export const updateSignalStatusAction = async ({ + query, + signalIds, + status, + setEventsLoading, + setEventsDeleted, + kbnVersion, +}: UpdateSignalStatusActionProps) => { + try { + setEventsLoading({ eventIds: signalIds, isLoading: true }); + + const queryObject = query ? { query: JSON.parse(query) } : getUpdateSignalsQuery(signalIds); + + await updateSignalStatus({ + query: queryObject, + status, + kbnVersion, + }); + // TODO: Only delete those that were successfully updated from updatedRules + setEventsDeleted({ eventIds: signalIds, isDeleted: true }); + } catch (e) { + // TODO: Show error toasts + } finally { + setEventsLoading({ eventIds: signalIds, isLoading: false }); + } +}; + +export const sendSignalsToTimelineAction = async ({ + createTimeline, + data, +}: SendSignalsToTimelineActionProps) => { + const stringFilter = data[0].filter(d => d.field === 'signal.rule.filters')?.[0]?.value ?? []; + + // TODO: Switch to using from/to when adding dateRange + // const [stringFilters, from, to] = getFilterAndRuleBounds(data); + const parsedFilter = stringFilter.map(sf => JSON.parse(sf)); + createTimeline({ + id: 'timeline-1', + filters: parsedFilter, + dateRange: undefined, // TODO + kqlQuery: undefined, // TODO + }); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/components/closed_signals/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/components/closed_signals/index.tsx deleted file mode 100644 index 7ccc95a1d708..000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/components/closed_signals/index.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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 React from 'react'; -import { - UtilityBar, - UtilityBarAction, - UtilityBarGroup, - UtilityBarSection, - UtilityBarText, -} from '../../../../../components/detection_engine/utility_bar'; -import * as i18n from '../../translations'; - -export const ClosedSignals = React.memo<{ totalCount: number }>(({ totalCount }) => { - return ( - <> - - - - {`${i18n.PANEL_SUBTITLE_SHOWING}: ${totalCount} signals`} - - - - - -

{'Customize columns context menu here.'}

} - > - {'Customize columns'} -
- - {'Aggregate data'} -
-
-
- - {/* Michael: Closed signals datagrid here. Talk to Chandler Prall about possibility of early access. If not possible, use basic table. */} - - ); -}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/components/open_signals/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/components/open_signals/index.tsx deleted file mode 100644 index f65f511ab33a..000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/components/open_signals/index.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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 React from 'react'; -import { - UtilityBar, - UtilityBarAction, - UtilityBarGroup, - UtilityBarSection, - UtilityBarText, -} from '../../../../../components/detection_engine/utility_bar'; -import * as i18n from '../../translations'; - -export const OpenSignals = React.memo<{ totalCount: number }>(({ totalCount }) => { - return ( - <> - - - - {`${i18n.PANEL_SUBTITLE_SHOWING}: ${totalCount} signals`} - - - - {'Selected: 20 signals'} - -

{'Batch actions context menu here.'}

} - > - {'Batch actions'} -
- - - {'Select all signals on all pages'} - -
-
-
- - {/* Michael: Open signals datagrid here. Talk to Chandler Prall about possibility of early access. If not possible, use basic table. */} - - ); -}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/components/signals_filter_group/signals_filter_group.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/components/signals_filter_group/signals_filter_group.tsx new file mode 100644 index 000000000000..445a7a9aea26 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/components/signals_filter_group/signals_filter_group.tsx @@ -0,0 +1,51 @@ +/* + * 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 { EuiFilterButton, EuiFilterGroup } from '@elastic/eui'; +import React, { useCallback, useState } from 'react'; +import * as i18n from '../../translations'; + +export const FILTER_OPEN = 'open'; +export const FILTER_CLOSED = 'closed'; +export type SignalFilterOption = typeof FILTER_OPEN | typeof FILTER_CLOSED; + +export const SignalsTableFilterGroup = React.memo( + ({ + onFilterGroupChanged, + }: { + onFilterGroupChanged: (filterGroup: SignalFilterOption) => void; + }) => { + const [filterGroup, setFilterGroup] = useState(FILTER_OPEN); + + const onClickOpenFilterCallback = useCallback(() => { + setFilterGroup(FILTER_OPEN); + onFilterGroupChanged(FILTER_OPEN); + }, [setFilterGroup, onFilterGroupChanged]); + + const onClickCloseFilterCallback = useCallback(() => { + setFilterGroup(FILTER_CLOSED); + onFilterGroupChanged(FILTER_CLOSED); + }, [setFilterGroup, onFilterGroupChanged]); + + return ( + + + {i18n.OPEN_SIGNALS} + + + + {i18n.CLOSED_SIGNALS} + + + ); + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/components/signals_utility_bar/batch_actions.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/components/signals_utility_bar/batch_actions.tsx new file mode 100644 index 000000000000..a45a968a029c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/components/signals_utility_bar/batch_actions.tsx @@ -0,0 +1,83 @@ +/* + * 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 { EuiContextMenuItem } from '@elastic/eui'; +import React from 'react'; +import * as i18n from './translations'; +import { TimelineNonEcsData } from '../../../../../graphql/types'; +import { SendSignalsToTimeline, UpdateSignalsStatus } from '../../types'; +import { FILTER_CLOSED, FILTER_OPEN } from '../signals_filter_group/signals_filter_group'; + +/** + * Returns ViewInTimeline / UpdateSignalStatus actions to be display within an EuiContextMenuPanel + * + * @param areEventsLoading are any events loading + * @param allEventsSelected are all events on all pages selected + * @param selectedEventIds + * @param updateSignalsStatus function for updating signal status + * @param sendSignalsToTimeline function for sending signals to timeline + * @param closePopover + * @param isFilteredToOpen currently selected filter options + */ +export const getBatchItems = ( + areEventsLoading: boolean, + allEventsSelected: boolean, + selectedEventIds: Readonly>, + updateSignalsStatus: UpdateSignalsStatus, + sendSignalsToTimeline: SendSignalsToTimeline, + closePopover: () => void, + isFilteredToOpen: boolean +) => { + const allDisabled = areEventsLoading || Object.keys(selectedEventIds).length === 0; + const sendToTimelineDisabled = allEventsSelected || uniqueRuleCount(selectedEventIds) > 1; + const filterString = isFilteredToOpen + ? i18n.BATCH_ACTION_CLOSE_SELECTED + : i18n.BATCH_ACTION_OPEN_SELECTED; + + return [ + { + closePopover(); + sendSignalsToTimeline(); + }} + > + {i18n.BATCH_ACTION_VIEW_SELECTED_IN_TIMELINE} + , + + { + closePopover(); + await updateSignalsStatus({ + signalIds: Object.keys(selectedEventIds), + status: isFilteredToOpen ? FILTER_CLOSED : FILTER_OPEN, + }); + }} + > + {filterString} + , + ]; +}; + +/** + * Returns the number of unique rules for a given list of signals + * + * @param signals + */ +export const uniqueRuleCount = ( + signals: Readonly> +): number => { + const ruleIds = Object.values(signals).flatMap( + data => data.find(d => d.field === 'signal.rule.id')?.value + ); + + return Array.from(new Set(ruleIds)).length; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/components/signals_utility_bar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/components/signals_utility_bar/index.tsx new file mode 100644 index 000000000000..4e392da6443b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/components/signals_utility_bar/index.tsx @@ -0,0 +1,135 @@ +/* + * 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 React, { useCallback } from 'react'; +import { EuiContextMenuPanel } from '@elastic/eui'; +import numeral from '@elastic/numeral'; +import { + UtilityBar, + UtilityBarAction, + UtilityBarGroup, + UtilityBarSection, + UtilityBarText, +} from '../../../../../components/detection_engine/utility_bar'; +import * as i18n from './translations'; +import { getBatchItems } from './batch_actions'; +import { useKibanaUiSetting } from '../../../../../lib/settings/use_kibana_ui_setting'; +import { DEFAULT_NUMBER_FORMAT } from '../../../../../../common/constants'; +import { TimelineNonEcsData } from '../../../../../graphql/types'; +import { SendSignalsToTimeline, UpdateSignalsStatus } from '../../types'; + +interface SignalsUtilityBarProps { + areEventsLoading: boolean; + clearSelection: () => void; + isFilteredToOpen: boolean; + selectAll: () => void; + selectedEventIds: Readonly>; + sendSignalsToTimeline: SendSignalsToTimeline; + showClearSelection: boolean; + totalCount: number; + updateSignalsStatus: UpdateSignalsStatus; +} + +export const SignalsUtilityBar = React.memo( + ({ + areEventsLoading, + clearSelection, + totalCount, + selectedEventIds, + isFilteredToOpen, + selectAll, + showClearSelection, + updateSignalsStatus, + sendSignalsToTimeline, + }) => { + const [defaultNumberFormat] = useKibanaUiSetting(DEFAULT_NUMBER_FORMAT); + + const getBatchItemsPopoverContent = useCallback( + (closePopover: () => void) => ( + + ), + [ + areEventsLoading, + selectedEventIds, + updateSignalsStatus, + sendSignalsToTimeline, + isFilteredToOpen, + ] + ); + + const formattedTotalCount = numeral(totalCount).format(defaultNumberFormat); + const formattedSelectedEventsCount = numeral(Object.keys(selectedEventIds).length).format( + defaultNumberFormat + ); + + return ( + <> + + + + + {i18n.SHOWING_SIGNALS(formattedTotalCount, totalCount)} + + + + + {totalCount > 0 && ( + <> + + {i18n.SELECTED_SIGNALS( + showClearSelection ? formattedTotalCount : formattedSelectedEventsCount, + showClearSelection ? totalCount : Object.keys(selectedEventIds).length + )} + + + + {i18n.BATCH_ACTIONS} + + + { + if (!showClearSelection) { + selectAll(); + } else { + clearSelection(); + } + }} + > + {showClearSelection + ? i18n.CLEAR_SELECTION + : i18n.SELECT_ALL_SIGNALS(formattedTotalCount, totalCount)} + + + )} + + + + + ); + }, + (prevProps, nextProps) => { + return ( + prevProps.selectedEventIds === nextProps.selectedEventIds && + prevProps.totalCount === nextProps.totalCount && + prevProps.showClearSelection === nextProps.showClearSelection + ); + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/components/signals_utility_bar/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/components/signals_utility_bar/translations.ts new file mode 100644 index 000000000000..b876177d5e4d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/components/signals_utility_bar/translations.ts @@ -0,0 +1,77 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const SHOWING_SIGNALS = (totalSignalsFormatted: string, totalSignals: number) => + i18n.translate('xpack.siem.detectionEngine.signals.utilityBar.showingSignalsTitle', { + values: { totalSignalsFormatted, totalSignals }, + defaultMessage: + 'Showing {totalSignalsFormatted} {totalSignals, plural, =1 {signal} other {signals}}', + }); + +export const SELECTED_SIGNALS = (selectedSignalsFormatted: string, selectedSignals: number) => + i18n.translate('xpack.siem.detectionEngine.signals.utilityBar.selectedSignalsTitle', { + values: { selectedSignalsFormatted, selectedSignals }, + defaultMessage: + 'Selected {selectedSignalsFormatted} {selectedSignals, plural, =1 {signal} other {signals}}', + }); + +export const SELECT_ALL_SIGNALS = (totalSignalsFormatted: string, totalSignals: number) => + i18n.translate('xpack.siem.detectionEngine.signals.utilityBar.selectAllSignalsTitle', { + values: { totalSignalsFormatted, totalSignals }, + defaultMessage: + 'Select all {totalSignalsFormatted} {totalSignals, plural, =1 {signal} other {signals}}', + }); + +export const CLEAR_SELECTION = i18n.translate( + 'xpack.siem.detectionEngine.signals.utilityBar.clearSelectionTitle', + { + defaultMessage: 'Clear selection', + } +); + +export const BATCH_ACTIONS = i18n.translate( + 'xpack.siem.detectionEngine.signals.utilityBar.batchActionsTitle', + { + defaultMessage: 'Batch actions', + } +); + +export const BATCH_ACTION_VIEW_SELECTED_IN_HOSTS = i18n.translate( + 'xpack.siem.detectionEngine.signals.utilityBar.batchActions.viewSelectedInHostsTitle', + { + defaultMessage: 'View selected in hosts', + } +); + +export const BATCH_ACTION_VIEW_SELECTED_IN_NETWORK = i18n.translate( + 'xpack.siem.detectionEngine.signals.utilityBar.batchActions.viewSelectedInNetworkTitle', + { + defaultMessage: 'View selected in network', + } +); + +export const BATCH_ACTION_VIEW_SELECTED_IN_TIMELINE = i18n.translate( + 'xpack.siem.detectionEngine.signals.utilityBar.batchActions.viewSelectedInTimelineTitle', + { + defaultMessage: 'View selected in timeline', + } +); + +export const BATCH_ACTION_OPEN_SELECTED = i18n.translate( + 'xpack.siem.detectionEngine.signals.utilityBar.batchActions.openSelectedTitle', + { + defaultMessage: 'Open selected', + } +); + +export const BATCH_ACTION_CLOSE_SELECTED = i18n.translate( + 'xpack.siem.detectionEngine.signals.utilityBar.batchActions.closeSelectedTitle', + { + defaultMessage: 'Close selected', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/default_config.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/default_config.tsx index e90487a3b023..d430262a1944 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/default_config.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/default_config.tsx @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import React from 'react'; import { ColumnHeader } from '../../../components/timeline/body/column_headers/column_header'; import { defaultColumnHeaderType } from '../../../components/timeline/body/column_headers/default_headers'; import { @@ -14,6 +16,11 @@ import { import * as i18n from './translations'; import { SubsetTimelineModel, timelineDefaults } from '../../../store/timeline/model'; import { esFilters } from '../../../../../../../../src/plugins/data/common/es_query'; +import { sendSignalsToTimelineAction, updateSignalStatusAction } from './actions'; +import { FILTER_OPEN } from './components/signals_filter_group/signals_filter_group'; + +import { TimelineAction, TimelineActionProps } from '../../../components/timeline/body/actions'; +import { CreateTimeline, SetEventsDeletedProps, SetEventsLoadingProps } from './types'; export const signalsOpenFilters: esFilters.Filter[] = [ { @@ -123,4 +130,74 @@ export const signalsHeaders: ColumnHeader[] = [ export const signalsDefaultModel: SubsetTimelineModel = { ...timelineDefaults, columns: signalsHeaders, + showCheckboxes: true, + showRowRenderers: false, }; + +export const requiredFieldsForActions = [ + '@timestamp', + 'signal.original_time', + 'signal.rule.filters', + 'signal.rule.from', + 'signal.rule.language', + 'signal.rule.query', + 'signal.rule.to', + 'signal.rule.id', +]; + +export const getSignalsActions = ({ + setEventsLoading, + setEventsDeleted, + createTimeline, + status, + kbnVersion, +}: { + setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void; + setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void; + createTimeline: CreateTimeline; + status: 'open' | 'closed'; + kbnVersion: string; +}): TimelineAction[] => [ + { + getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => ( + + sendSignalsToTimelineAction({ createTimeline, data: [data] })} + iconType="tableDensityNormal" + aria-label="Next" + /> + + ), + id: 'sendSignalToTimeline', + width: 26, + }, + { + getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => ( + + + updateSignalStatusAction({ + signalIds: [eventId], + status, + setEventsLoading, + setEventsDeleted, + kbnVersion, + }) + } + iconType={status === FILTER_OPEN ? 'indexOpen' : 'indexClose'} + aria-label="Next" + /> + + ), + id: 'updateSignalStatus', + width: 26, + }, +]; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/index.tsx index 74b7b9349c2c..e99268efb9da 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/index.tsx @@ -4,94 +4,338 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFilterButton, EuiFilterGroup } from '@elastic/eui'; -import React, { useCallback, useState } from 'react'; -import { OpenSignals } from './components/open_signals'; -import { ClosedSignals } from './components/closed_signals'; -import { GlobalTime } from '../../../containers/global_time'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { connect } from 'react-redux'; +import { ActionCreator } from 'typescript-fsa'; +import { SignalsUtilityBar } from './components/signals_utility_bar'; import { StatefulEventsViewer } from '../../../components/events_viewer'; import * as i18n from './translations'; -import { DEFAULT_SIGNALS_INDEX } from '../../../../common/constants'; -import { signalsClosedFilters, signalsDefaultModel, signalsOpenFilters } from './default_config'; +import { + getSignalsActions, + requiredFieldsForActions, + signalsClosedFilters, + signalsDefaultModel, + signalsOpenFilters, +} from './default_config'; +import { timelineActions, timelineSelectors } from '../../../store/timeline'; +import { timelineDefaults, TimelineModel } from '../../../store/timeline/model'; +import { + FILTER_CLOSED, + FILTER_OPEN, + SignalFilterOption, + SignalsTableFilterGroup, +} from './components/signals_filter_group/signals_filter_group'; +import { useKibanaUiSetting } from '../../../lib/settings/use_kibana_ui_setting'; +import { DEFAULT_KBN_VERSION, DEFAULT_SIGNALS_INDEX } from '../../../../common/constants'; +import { defaultHeaders } from '../../../components/timeline/body/column_headers/default_headers'; +import { ColumnHeader } from '../../../components/timeline/body/column_headers/column_header'; +import { esFilters, esQuery } from '../../../../../../../../src/plugins/data/common/es_query'; +import { TimelineNonEcsData } from '../../../graphql/types'; +import { inputsSelectors, SerializedFilterQuery, State } from '../../../store'; +import { sendSignalsToTimelineAction, updateSignalStatusAction } from './actions'; +import { + CreateTimelineProps, + SendSignalsToTimeline, + SetEventsDeletedProps, + SetEventsLoadingProps, + UpdateSignalsStatus, + UpdateSignalsStatusProps, +} from './types'; +import { inputsActions } from '../../../store/inputs'; +import { combineQueries } from '../../../components/timeline/helpers'; +import { useKibanaCore } from '../../../lib/compose/kibana_core'; +import { useFetchIndexPatterns } from '../../../containers/detection_engine/rules/fetch_index_patterns'; +import { InputsRange } from '../../../store/inputs/model'; +import { Query } from '../../../../../../../../src/plugins/data/common/query'; const SIGNALS_PAGE_TIMELINE_ID = 'signals-page'; -const FILTER_OPEN = 'open'; -const FILTER_CLOSED = 'closed'; -export const SignalsTableFilterGroup = React.memo( - ({ onFilterGroupChanged }: { onFilterGroupChanged: (filterGroup: string) => void }) => { - const [filterGroup, setFilterGroup] = useState(FILTER_OPEN); +interface ReduxProps { + globalQuery: Query; + globalFilters: esFilters.Filter[]; + deletedEventIds: string[]; + isSelectAllChecked: boolean; + loadingEventIds: string[]; + selectedEventIds: Readonly>; +} + +interface DispatchProps { + createTimeline: ActionCreator<{ + dateRange?: { + start: number; + end: number; + }; + filters?: esFilters.Filter[]; + id: string; + kqlQuery?: { + filterQuery: SerializedFilterQuery | null; + }; + columns: ColumnHeader[]; + show?: boolean; + }>; + clearEventsDeleted?: ActionCreator<{ id: string }>; + clearEventsLoading?: ActionCreator<{ id: string }>; + clearSelected?: ActionCreator<{ id: string }>; + removeTimelineLinkTo: ActionCreator<{}>; + setEventsDeleted?: ActionCreator<{ + id: string; + eventIds: string[]; + isDeleted: boolean; + }>; + setEventsLoading?: ActionCreator<{ + id: string; + eventIds: string[]; + isLoading: boolean; + }>; +} + +interface OwnProps { + defaultFilters?: esFilters.Filter[]; + from: number; + to: number; +} + +type SignalsTableComponentProps = OwnProps & ReduxProps & DispatchProps; + +export const SignalsTableComponent = React.memo( + ({ + createTimeline, + clearEventsDeleted, + clearEventsLoading, + clearSelected, + defaultFilters = [], + from, + globalFilters, + globalQuery, + isSelectAllChecked, + loadingEventIds, + removeTimelineLinkTo, + selectedEventIds, + setEventsDeleted, + setEventsLoading, + to, + }) => { + const [selectAll, setSelectAll] = useState(false); + + const [showClearSelectionAction, setShowClearSelectionAction] = useState(false); + const [filterGroup, setFilterGroup] = useState(FILTER_OPEN); + const [{ browserFields, indexPatterns }] = useFetchIndexPatterns([DEFAULT_SIGNALS_INDEX]); // TODO Get from new FrankInspired XavierHook + const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION); + const core = useKibanaCore(); + + const getGlobalQuery = useCallback(() => { + if (browserFields != null && indexPatterns != null) { + return combineQueries({ + config: esQuery.getEsQueryConfig(core.uiSettings), + dataProviders: [], + indexPattern: indexPatterns, + browserFields, + filters: globalFilters, + kqlQuery: globalQuery, + kqlMode: globalQuery.language, + start: from, + end: to, + isEventViewer: true, + }); + } + return null; + }, [browserFields, globalFilters, globalQuery, indexPatterns, to, from]); + + // Callback for creating a new timeline -- utilized by row/batch actions + const createTimelineCallback = useCallback( + ({ id, kqlQuery, filters, dateRange }: CreateTimelineProps) => { + removeTimelineLinkTo({}); + createTimeline({ id, columns: defaultHeaders, show: true, filters, dateRange, kqlQuery }); + }, + [createTimeline, removeTimelineLinkTo] + ); + + const setEventsLoadingCallback = useCallback( + ({ eventIds, isLoading }: SetEventsLoadingProps) => { + setEventsLoading!({ id: SIGNALS_PAGE_TIMELINE_ID, eventIds, isLoading }); + }, + [setEventsLoading, SIGNALS_PAGE_TIMELINE_ID] + ); + + const setEventsDeletedCallback = useCallback( + ({ eventIds, isDeleted }: SetEventsDeletedProps) => { + setEventsDeleted!({ id: SIGNALS_PAGE_TIMELINE_ID, eventIds, isDeleted }); + }, + [setEventsDeleted, SIGNALS_PAGE_TIMELINE_ID] + ); + + // Catches state change isSelectAllChecked->false upon user selection change to reset utility bar + useEffect(() => { + if (!isSelectAllChecked) { + setShowClearSelectionAction(false); + } else { + setSelectAll(false); + } + }, [isSelectAllChecked]); + + // Callback for when open/closed filter changes + const onFilterGroupChangedCallback = useCallback( + (newFilterGroup: SignalFilterOption) => { + clearEventsLoading!({ id: SIGNALS_PAGE_TIMELINE_ID }); + clearEventsDeleted!({ id: SIGNALS_PAGE_TIMELINE_ID }); + clearSelected!({ id: SIGNALS_PAGE_TIMELINE_ID }); + setFilterGroup(newFilterGroup); + }, + [setFilterGroup] + ); + + // Callback for clearing entire selection from utility bar + const clearSelectionCallback = useCallback(() => { + clearSelected!({ id: SIGNALS_PAGE_TIMELINE_ID }); + setSelectAll(false); + setShowClearSelectionAction(false); + }, [clearSelected, setShowClearSelectionAction]); + + // Callback for selecting all events on all pages from utility bar + // Dispatches to stateful_body's selectAll via TimelineTypeContext props + // as scope of response data required to actually set selectedEvents + const selectAllCallback = useCallback(() => { + setSelectAll(true); + setShowClearSelectionAction(true); + }, [setShowClearSelectionAction]); + + const updateSignalsStatusCallback: UpdateSignalsStatus = useCallback( + async ({ signalIds, status }: UpdateSignalsStatusProps) => { + await updateSignalStatusAction({ + query: showClearSelectionAction ? getGlobalQuery()?.filterQuery : undefined, + signalIds: Object.keys(selectedEventIds), + status, + setEventsDeleted: setEventsDeletedCallback, + setEventsLoading: setEventsLoadingCallback, + kbnVersion, + }); + }, + [ + getGlobalQuery, + selectedEventIds, + setEventsDeletedCallback, + setEventsLoadingCallback, + showClearSelectionAction, + ] + ); + const sendSignalsToTimelineCallback: SendSignalsToTimeline = useCallback(async () => { + await sendSignalsToTimelineAction({ + createTimeline: createTimelineCallback, + data: Object.values(selectedEventIds), + }); + }, [selectedEventIds, setEventsDeletedCallback, setEventsLoadingCallback]); + + // Callback for creating the SignalUtilityBar which receives totalCount from EventsViewer component + const utilityBarCallback = useCallback( + (totalCount: number) => { + return ( + 0} + clearSelection={clearSelectionCallback} + isFilteredToOpen={filterGroup === FILTER_OPEN} + selectAll={selectAllCallback} + selectedEventIds={selectedEventIds} + sendSignalsToTimeline={sendSignalsToTimelineCallback} + showClearSelection={showClearSelectionAction} + totalCount={totalCount} + updateSignalsStatus={updateSignalsStatusCallback} + /> + ); + }, + [ + clearSelectionCallback, + filterGroup, + loadingEventIds.length, + selectAllCallback, + selectedEventIds, + showClearSelectionAction, + ] + ); + + // Send to Timeline / Update Signal Status Actions for each table row + const additionalActions = useMemo( + () => + getSignalsActions({ + createTimeline: createTimelineCallback, + setEventsLoading: setEventsLoadingCallback, + setEventsDeleted: setEventsDeletedCallback, + status: filterGroup === FILTER_OPEN ? FILTER_CLOSED : FILTER_OPEN, + kbnVersion, + }), + [createTimelineCallback, filterGroup, kbnVersion] + ); + + const defaultIndices = useMemo(() => [`${DEFAULT_SIGNALS_INDEX}`], [DEFAULT_SIGNALS_INDEX]); + const defaultFiltersMemo = useMemo( + () => [ + ...defaultFilters, + ...(filterGroup === FILTER_OPEN ? signalsOpenFilters : signalsClosedFilters), + ], + [defaultFilters, filterGroup] + ); + + const timelineTypeContext = useMemo( + () => ({ + documentType: i18n.SIGNALS_DOCUMENT_TYPE, + footerText: i18n.TOTAL_COUNT_OF_SIGNALS, + loadingText: i18n.LOADING_SIGNALS, + queryFields: requiredFieldsForActions, + timelineActions: additionalActions, + title: i18n.SIGNALS_TABLE_TITLE, + selectAll, + }), + [additionalActions, selectAll] + ); return ( - - { - setFilterGroup(FILTER_OPEN); - onFilterGroupChanged(FILTER_OPEN); - }} - withNext - > - {'Open signals'} - - - { - setFilterGroup(FILTER_CLOSED); - onFilterGroupChanged(FILTER_CLOSED); - }} - > - {'Closed signals'} - - + + } + id={SIGNALS_PAGE_TIMELINE_ID} + start={from} + timelineTypeContext={timelineTypeContext} + utilityBar={utilityBarCallback} + /> ); } ); -export const SignalsTable = React.memo(() => { - const [filterGroup, setFilterGroup] = useState(FILTER_OPEN); +SignalsTableComponent.displayName = 'SignalsTableComponent'; - const onFilterGroupChangedCallback = useCallback( - (newFilterGroup: string) => { - setFilterGroup(newFilterGroup); - }, - [setFilterGroup] - ); +const makeMapStateToProps = () => { + const getTimeline = timelineSelectors.getTimelineByIdSelector(); + const getGlobalInputs = inputsSelectors.globalSelector(); + const mapStateToProps = (state: State) => { + const timeline: TimelineModel = + getTimeline(state, SIGNALS_PAGE_TIMELINE_ID) ?? timelineDefaults; + const { deletedEventIds, isSelectAllChecked, loadingEventIds, selectedEventIds } = timeline; - return ( - <> - - {({ to, from, setQuery, deleteQuery, isInitializing }) => ( - - } - id={SIGNALS_PAGE_TIMELINE_ID} - start={from} - timelineTypeContext={{ - documentType: i18n.SIGNALS_DOCUMENT_TYPE, - footerText: i18n.TOTAL_COUNT_OF_SIGNALS, - showCheckboxes: true, - showRowRenderers: false, - title: i18n.SIGNALS_TABLE_TITLE, - }} - utilityBar={(totalCount: number) => - filterGroup === FILTER_OPEN ? ( - - ) : ( - - ) - } - /> - )} - - - ); -}); + const globalInputs: InputsRange = getGlobalInputs(state); + const { query, filters } = globalInputs; -SignalsTable.displayName = 'SignalsTable'; + return { + globalQuery: query, + globalFilters: filters, + deletedEventIds, + isSelectAllChecked, + loadingEventIds, + selectedEventIds, + }; + }; + return mapStateToProps; +}; + +export const SignalsTable = connect(makeMapStateToProps, { + removeTimelineLinkTo: inputsActions.removeTimelineLinkTo, + clearSelected: timelineActions.clearSelected, + setEventsLoading: timelineActions.setEventsLoading, + clearEventsLoading: timelineActions.clearEventsLoading, + setEventsDeleted: timelineActions.setEventsDeleted, + clearEventsDeleted: timelineActions.clearEventsDeleted, + createTimeline: timelineActions.createTimeline, +})(SignalsTableComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/translations.ts index 1806ba85f8b5..d1ba946be41d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/translations.ts @@ -10,13 +10,6 @@ export const PAGE_TITLE = i18n.translate('xpack.siem.detectionEngine.pageTitle', defaultMessage: 'Detection engine', }); -export const PANEL_SUBTITLE_SHOWING = i18n.translate( - 'xpack.siem.detectionEngine.panelSubtitleShowing', - { - defaultMessage: 'Showing', - } -); - export const SIGNALS_TABLE_TITLE = i18n.translate('xpack.siem.detectionEngine.signals.tableTitle', { defaultMessage: 'All signals', }); @@ -28,6 +21,24 @@ export const SIGNALS_DOCUMENT_TYPE = i18n.translate( } ); +export const OPEN_SIGNALS = i18n.translate('xpack.siem.detectionEngine.signals.openSignalsTitle', { + defaultMessage: 'Open signals', +}); + +export const CLOSED_SIGNALS = i18n.translate( + 'xpack.siem.detectionEngine.signals.closedSignalsTitle', + { + defaultMessage: 'Closed signals', + } +); + +export const LOADING_SIGNALS = i18n.translate( + 'xpack.siem.detectionEngine.signals.loadingSignalsTitle', + { + defaultMessage: 'Loading Signals', + } +); + export const TOTAL_COUNT_OF_SIGNALS = i18n.translate( 'xpack.siem.detectionEngine.signals.totalCountOfSignalsTitle', { @@ -62,3 +73,24 @@ export const SIGNALS_HEADERS_RISK_SCORE = i18n.translate( defaultMessage: 'Risk Score', } ); + +export const ACTION_OPEN_SIGNAL = i18n.translate( + 'xpack.siem.detectionEngine.signals.actions.openSignalTitle', + { + defaultMessage: 'Open signal', + } +); + +export const ACTION_CLOSE_SIGNAL = i18n.translate( + 'xpack.siem.detectionEngine.signals.actions.closeSignalTitle', + { + defaultMessage: 'Close signal', + } +); + +export const ACTION_VIEW_IN_TIMELINE = i18n.translate( + 'xpack.siem.detectionEngine.signals.actions.viewInTimelineTitle', + { + defaultMessage: 'View in timeline', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/types.ts new file mode 100644 index 000000000000..977fdf8a01f1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/signals/types.ts @@ -0,0 +1,54 @@ +/* + * 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 { esFilters } from '../../../../../../../../src/plugins/data/common/es_query'; +import { TimelineNonEcsData } from '../../../graphql/types'; +import { KueryFilterQuery, SerializedFilterQuery } from '../../../store'; + +export interface SetEventsLoadingProps { + eventIds: string[]; + isLoading: boolean; +} + +export interface SetEventsDeletedProps { + eventIds: string[]; + isDeleted: boolean; +} + +export interface UpdateSignalsStatusProps { + signalIds: string[]; + status: 'open' | 'closed'; +} + +export type UpdateSignalsStatus = ({ signalIds, status }: UpdateSignalsStatusProps) => void; + +export interface UpdateSignalStatusActionProps { + query?: string; + signalIds: string[]; + status: 'open' | 'closed'; + setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void; + setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void; + kbnVersion: string; +} + +export type SendSignalsToTimeline = () => void; + +export interface SendSignalsToTimelineActionProps { + createTimeline: CreateTimeline; + data: TimelineNonEcsData[][]; +} + +export interface CreateTimelineProps { + id: string; + kqlQuery?: { + filterQuery: SerializedFilterQuery | null; + filterQueryDraft: KueryFilterQuery | null; + }; + filters?: esFilters.Filter[]; + dateRange?: { start: number; end: number }; +} + +export type CreateTimeline = ({ id, kqlQuery, filters, dateRange }: CreateTimelineProps) => void; diff --git a/x-pack/legacy/plugins/siem/public/store/timeline/actions.ts b/x-pack/legacy/plugins/siem/public/store/timeline/actions.ts index 931d3e26172c..f8d23ac72e20 100644 --- a/x-pack/legacy/plugins/siem/public/store/timeline/actions.ts +++ b/x-pack/legacy/plugins/siem/public/store/timeline/actions.ts @@ -16,6 +16,7 @@ import { import { KueryFilterQuery, SerializedFilterQuery } from '../model'; import { KqlMode, TimelineModel } from './model'; +import { TimelineNonEcsData } from '../../graphql/types'; const actionCreator = actionCreatorFactory('x-pack/siem/local/timeline'); @@ -49,10 +50,21 @@ export const applyDeltaToColumnWidth = actionCreator<{ export const createTimeline = actionCreator<{ id: string; + dateRange?: { + start: number; + end: number; + }; + filters?: esFilters.Filter[]; columns: ColumnHeader[]; itemsPerPage?: number; + kqlQuery?: { + filterQuery: SerializedFilterQuery | null; + filterQueryDraft: KueryFilterQuery | null; + }; show?: boolean; sort?: Sort; + showCheckboxes?: boolean; + showRowRenderers?: boolean; }>('CREATE_TIMELINE'); export const pinEvent = actionCreator<{ id: string; eventId: string }>('PIN_EVENT'); @@ -198,3 +210,34 @@ export const setFilters = actionCreator<{ id: string; filters: esFilters.Filter[]; }>('SET_TIMELINE_FILTERS'); + +export const setSelected = actionCreator<{ + id: string; + eventIds: Readonly>; + isSelected: boolean; + isSelectAllChecked: boolean; +}>('SET_TIMELINE_SELECTED'); + +export const clearSelected = actionCreator<{ + id: string; +}>('CLEAR_TIMELINE_SELECTED'); + +export const setEventsLoading = actionCreator<{ + id: string; + eventIds: string[]; + isLoading: boolean; +}>('SET_TIMELINE_EVENTS_LOADING'); + +export const clearEventsLoading = actionCreator<{ + id: string; +}>('CLEAR_TIMELINE_EVENTS_LOADING'); + +export const setEventsDeleted = actionCreator<{ + id: string; + eventIds: string[]; + isDeleted: boolean; +}>('SET_TIMELINE_EVENTS_DELETED'); + +export const clearEventsDeleted = actionCreator<{ + id: string; +}>('CLEAR_TIMELINE_EVENTS_DELETED'); diff --git a/x-pack/legacy/plugins/siem/public/store/timeline/epic.test.ts b/x-pack/legacy/plugins/siem/public/store/timeline/epic.test.ts index c04da21e6bb2..6e62ce8cb8b0 100644 --- a/x-pack/legacy/plugins/siem/public/store/timeline/epic.test.ts +++ b/x-pack/legacy/plugins/siem/public/store/timeline/epic.test.ts @@ -85,6 +85,7 @@ describe('Epic Timeline', () => { ], }, ], + deletedEventIds: [], description: '', eventIdToNoteIds: {}, highlightedDropAndProviderId: '', @@ -117,6 +118,7 @@ describe('Epic Timeline', () => { ], isFavorite: false, isLive: false, + isSelectAllChecked: false, isLoading: false, isSaving: false, itemsPerPage: 25, @@ -130,13 +132,17 @@ describe('Epic Timeline', () => { }, filterQueryDraft: { kind: 'kuery', expression: 'endgame.user_name : "zeus" ' }, }, + loadingEventIds: [], title: 'saved', noteIds: [], pinnedEventIds: {}, pinnedEventsSaveObject: {}, dateRange: { start: 1572469587644, end: 1572555987644 }, savedObjectId: '11169110-fc22-11e9-8ca9-072f15ce2685', + selectedEventIds: {}, show: true, + showCheckboxes: false, + showRowRenderers: true, sort: { columnId: '@timestamp', sortDirection: Direction.desc }, width: 1100, version: 'WzM4LDFd', diff --git a/x-pack/legacy/plugins/siem/public/store/timeline/helpers.ts b/x-pack/legacy/plugins/siem/public/store/timeline/helpers.ts index 16ae53ade796..1f79a38b9f5b 100644 --- a/x-pack/legacy/plugins/siem/public/store/timeline/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/store/timeline/helpers.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getOr, omit, uniq, isEmpty, isEqualWith } from 'lodash/fp'; +import { getOr, omit, uniq, isEmpty, isEqualWith, union } from 'lodash/fp'; import { esFilters } from '../../../../../../../src/plugins/data/public'; import { ColumnHeader } from '../../components/timeline/body/column_headers/column_header'; @@ -19,6 +19,7 @@ import { KueryFilterQuery, SerializedFilterQuery } from '../model'; import { KqlMode, timelineDefaults, TimelineModel } from './model'; import { TimelineById, TimelineState } from './types'; +import { TimelineNonEcsData } from '../../graphql/types'; const EMPTY_TIMELINE_BY_ID: TimelineById = {}; // stable reference @@ -129,20 +130,36 @@ export const addTimelineToStore = ({ interface AddNewTimelineParams { columns: ColumnHeader[]; + dateRange?: { + start: number; + end: number; + }; + filters?: esFilters.Filter[]; id: string; itemsPerPage?: number; + kqlQuery?: { + filterQuery: SerializedFilterQuery | null; + filterQueryDraft: KueryFilterQuery | null; + }; show?: boolean; sort?: Sort; + showCheckboxes?: boolean; + showRowRenderers?: boolean; timelineById: TimelineById; } /** Adds a new `Timeline` to the provided collection of `TimelineById` */ export const addNewTimeline = ({ columns, + dateRange = { start: 0, end: 0 }, + filters = timelineDefaults.filters, id, itemsPerPage = timelineDefaults.itemsPerPage, + kqlQuery = { filterQuery: null, filterQueryDraft: null }, sort = timelineDefaults.sort, show = false, + showCheckboxes = false, + showRowRenderers = true, timelineById, }: AddNewTimelineParams): TimelineById => ({ ...timelineById, @@ -150,13 +167,18 @@ export const addNewTimeline = ({ id, ...timelineDefaults, columns, + dateRange, + filters, itemsPerPage, + kqlQuery, sort, show, savedObjectId: null, version: null, isSaving: false, isLoading: false, + showCheckboxes, + showRowRenderers, }, }); @@ -1095,6 +1117,93 @@ export const removeTimelineProvider = ({ }; }; +interface SetDeletedTimelineEventsParams { + id: string; + eventIds: string[]; + isDeleted: boolean; + timelineById: TimelineById; +} + +export const setDeletedTimelineEvents = ({ + id, + eventIds, + isDeleted, + timelineById, +}: SetDeletedTimelineEventsParams): TimelineById => { + const timeline = timelineById[id]; + + const deletedEventIds = isDeleted + ? union(timeline.deletedEventIds, eventIds) + : timeline.deletedEventIds.filter(currentEventId => !eventIds.includes(currentEventId)); + + return { + ...timelineById, + [id]: { + ...timeline, + deletedEventIds, + }, + }; +}; + +interface SetLoadingTimelineEventsParams { + id: string; + eventIds: string[]; + isLoading: boolean; + timelineById: TimelineById; +} + +export const setLoadingTimelineEvents = ({ + id, + eventIds, + isLoading, + timelineById, +}: SetLoadingTimelineEventsParams): TimelineById => { + const timeline = timelineById[id]; + + const loadingEventIds = isLoading + ? union(timeline.loadingEventIds, eventIds) + : timeline.loadingEventIds.filter(currentEventId => !eventIds.includes(currentEventId)); + + return { + ...timelineById, + [id]: { + ...timeline, + loadingEventIds, + }, + }; +}; + +interface SetSelectedTimelineEventsParams { + id: string; + eventIds: Record; + isSelectAllChecked: boolean; + isSelected: boolean; + timelineById: TimelineById; +} + +export const setSelectedTimelineEvents = ({ + id, + eventIds, + isSelectAllChecked = false, + isSelected, + timelineById, +}: SetSelectedTimelineEventsParams): TimelineById => { + const timeline = timelineById[id]; + + const selectedEventIds = isSelected + ? { ...timeline.selectedEventIds, ...eventIds } + : omit(Object.keys(eventIds), timeline.selectedEventIds); + + return { + ...timelineById, + [id]: { + ...timeline, + selectedEventIds, + isSelectAllChecked, + }, + }; +}; + interface UnPinTimelineEventParams { id: string; eventId: string; diff --git a/x-pack/legacy/plugins/siem/public/store/timeline/model.ts b/x-pack/legacy/plugins/siem/public/store/timeline/model.ts index 401edb73a0de..517392857e4a 100644 --- a/x-pack/legacy/plugins/siem/public/store/timeline/model.ts +++ b/x-pack/legacy/plugins/siem/public/store/timeline/model.ts @@ -10,7 +10,7 @@ import { DataProvider } from '../../components/timeline/data_providers/data_prov import { DEFAULT_TIMELINE_WIDTH } from '../../components/timeline/body/helpers'; import { defaultHeaders } from '../../components/timeline/body/column_headers/default_headers'; import { Sort } from '../../components/timeline/body/sort'; -import { Direction, PinnedEvent } from '../../graphql/types'; +import { Direction, PinnedEvent, TimelineNonEcsData } from '../../graphql/types'; import { KueryFilterQuery, SerializedFilterQuery } from '../model'; export const DEFAULT_PAGE_COUNT = 2; // Eui Pager will not render unless this is a minimum of 2 pages @@ -21,6 +21,8 @@ export interface TimelineModel { columns: ColumnHeader[]; /** The sources of the event data shown in the timeline */ dataProviders: DataProvider[]; + /** Events to not be rendered **/ + deletedEventIds: string[]; /** A summary of the events and notes in this timeline */ description: string; /** A map of events in this timeline to the chronologically ordered notes (in this timeline) associated with the event */ @@ -32,6 +34,10 @@ export interface TimelineModel { highlightedDropAndProviderId: string; /** Uniquely identifies the timeline */ id: string; + /** If selectAll checkbox in header is checked **/ + isSelectAllChecked: boolean; + /** Events to be rendered as loading **/ + loadingEventIds: string[]; savedObjectId: string | null; /** When true, this timeline was marked as "favorite" by the user */ isFavorite: boolean; @@ -61,8 +67,14 @@ export interface TimelineModel { end: number; }; savedQueryId?: string | null; + /** Events selected on this timeline -- eventId to TimelineNonEcsData[] mapping of data required for batch actions **/ + selectedEventIds: Record; /** When true, show the timeline flyover */ show: boolean; + /** When true, shows checkboxes enabling selection. Selected events store in selectedEventIds **/ + showCheckboxes: boolean; + /** When true, shows additional rowRenderers below the PlainRowRenderer **/ + showRowRenderers: boolean; /** Specifies which column the timeline is sorted on, and the direction (ascending / descending) */ sort: Sort; /** Persists the UI state (width) of the timeline flyover */ @@ -78,22 +90,28 @@ export type SubsetTimelineModel = Readonly< TimelineModel, | 'columns' | 'dataProviders' + | 'deletedEventIds' | 'description' | 'eventIdToNoteIds' | 'highlightedDropAndProviderId' | 'historyIds' | 'isFavorite' | 'isLive' + | 'isSelectAllChecked' | 'itemsPerPage' | 'itemsPerPageOptions' | 'kqlMode' | 'kqlQuery' | 'title' + | 'loadingEventIds' | 'noteIds' | 'pinnedEventIds' | 'pinnedEventsSaveObject' | 'dateRange' + | 'selectedEventIds' | 'show' + | 'showCheckboxes' + | 'showRowRenderers' | 'sort' | 'width' | 'isSaving' @@ -106,6 +124,7 @@ export type SubsetTimelineModel = Readonly< export const timelineDefaults: SubsetTimelineModel & Pick = { columns: defaultHeaders, dataProviders: [], + deletedEventIds: [], description: '', eventIdToNoteIds: {}, highlightedDropAndProviderId: '', @@ -113,6 +132,7 @@ export const timelineDefaults: SubsetTimelineModel & Pick { }, }, ], + deletedEventIds: [], description: '', eventIdToNoteIds: {}, highlightedDropAndProviderId: '', historyIds: [], isFavorite: false, isLive: false, + isSelectAllChecked: false, isLoading: false, kqlMode: 'filter', kqlQuery: { filterQuery: null, filterQueryDraft: null }, + loadingEventIds: [], title: '', noteIds: [], dateRange: { start: 0, end: 0, }, + selectedEventIds: {}, show: true, + showRowRenderers: true, + showCheckboxes: false, sort: { columnId: '@timestamp', sortDirection: Direction.desc, @@ -1174,21 +1186,27 @@ describe('Timeline', () => { }, ], description: '', + deletedEventIds: [], eventIdToNoteIds: {}, highlightedDropAndProviderId: '', historyIds: [], isFavorite: false, isLive: false, + isSelectAllChecked: false, isLoading: false, kqlMode: 'filter', kqlQuery: { filterQuery: null, filterQueryDraft: null }, + loadingEventIds: [], title: '', noteIds: [], dateRange: { start: 0, end: 0, }, + selectedEventIds: {}, show: true, + showRowRenderers: true, + showCheckboxes: false, sort: { columnId: '@timestamp', sortDirection: Direction.desc, @@ -1366,21 +1384,27 @@ describe('Timeline', () => { }, ], description: '', + deletedEventIds: [], eventIdToNoteIds: {}, highlightedDropAndProviderId: '', historyIds: [], isFavorite: false, isLive: false, + isSelectAllChecked: false, isLoading: false, kqlMode: 'filter', kqlQuery: { filterQuery: null, filterQueryDraft: null }, + loadingEventIds: [], title: '', noteIds: [], dateRange: { start: 0, end: 0, }, + selectedEventIds: {}, show: true, + showRowRenderers: true, + showCheckboxes: false, sort: { columnId: '@timestamp', sortDirection: Direction.desc, @@ -1452,21 +1476,27 @@ describe('Timeline', () => { }, ], description: '', + deletedEventIds: [], eventIdToNoteIds: {}, highlightedDropAndProviderId: '', historyIds: [], isFavorite: false, isLive: false, + isSelectAllChecked: false, isLoading: false, kqlMode: 'filter', kqlQuery: { filterQuery: null, filterQueryDraft: null }, + loadingEventIds: [], title: '', noteIds: [], dateRange: { start: 0, end: 0, }, + selectedEventIds: {}, show: true, + showRowRenderers: true, + showCheckboxes: false, sort: { columnId: '@timestamp', sortDirection: Direction.desc, @@ -1633,21 +1663,27 @@ describe('Timeline', () => { }, ], description: '', + deletedEventIds: [], eventIdToNoteIds: {}, highlightedDropAndProviderId: '', historyIds: [], isFavorite: false, isLive: false, + isSelectAllChecked: false, isLoading: false, kqlMode: 'filter', kqlQuery: { filterQuery: null, filterQueryDraft: null }, + loadingEventIds: [], title: '', noteIds: [], dateRange: { start: 0, end: 0, }, + selectedEventIds: {}, show: true, + showRowRenderers: true, + showCheckboxes: false, sort: { columnId: '@timestamp', sortDirection: Direction.desc, @@ -1701,23 +1737,29 @@ describe('Timeline', () => { }, ], description: '', + deletedEventIds: [], eventIdToNoteIds: {}, highlightedDropAndProviderId: '', historyIds: [], isFavorite: false, isLive: false, + isSelectAllChecked: false, isLoading: false, id: 'foo', savedObjectId: null, kqlMode: 'filter', kqlQuery: { filterQuery: null, filterQueryDraft: null }, + loadingEventIds: [], title: '', noteIds: [], dateRange: { start: 0, end: 0, }, + selectedEventIds: {}, show: true, + showRowRenderers: true, + showCheckboxes: false, sort: { columnId: '@timestamp', sortDirection: Direction.desc, @@ -1795,6 +1837,7 @@ describe('Timeline', () => { }, ], description: '', + deletedEventIds: [], eventIdToNoteIds: {}, highlightedDropAndProviderId: '', historyIds: [], @@ -1802,16 +1845,21 @@ describe('Timeline', () => { savedObjectId: null, isFavorite: false, isLive: false, + isSelectAllChecked: false, isLoading: false, kqlMode: 'filter', kqlQuery: { filterQuery: null, filterQueryDraft: null }, + loadingEventIds: [], title: '', noteIds: [], dateRange: { start: 0, end: 0, }, + selectedEventIds: {}, show: true, + showRowRenderers: true, + showCheckboxes: false, sort: { columnId: '@timestamp', sortDirection: Direction.desc, diff --git a/x-pack/legacy/plugins/siem/public/store/timeline/reducer.ts b/x-pack/legacy/plugins/siem/public/store/timeline/reducer.ts index 3b88785fa906..f66638d64460 100644 --- a/x-pack/legacy/plugins/siem/public/store/timeline/reducer.ts +++ b/x-pack/legacy/plugins/siem/public/store/timeline/reducer.ts @@ -20,7 +20,13 @@ import { pinEvent, removeColumn, removeProvider, + setEventsDeleted, + clearEventsDeleted, + setEventsLoading, + clearEventsLoading, setKqlFilterQueryDraft, + setSelected, + clearSelected, showCallOutUnauthorizedMsg, showTimeline, startTimelineSaving, @@ -61,6 +67,9 @@ import { pinTimelineEvent, removeTimelineColumn, removeTimelineProvider, + setDeletedTimelineEvents, + setLoadingTimelineEvents, + setSelectedTimelineEvents, unPinTimelineEvent, updateHighlightedDropAndProvider, updateKqlFilterQueryDraft, @@ -103,17 +112,39 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) ...state, timelineById: addTimelineToStore({ id, timeline, timelineById: state.timelineById }), })) - .case(createTimeline, (state, { id, show, columns, itemsPerPage, sort }) => ({ - ...state, - timelineById: addNewTimeline({ - columns, - id, - itemsPerPage, - sort, - show, - timelineById: state.timelineById, - }), - })) + .case( + createTimeline, + ( + state, + { + id, + dateRange, + show, + columns, + itemsPerPage, + kqlQuery, + sort, + showCheckboxes, + showRowRenderers, + filters, + } + ) => ({ + ...state, + timelineById: addNewTimeline({ + columns, + dateRange, + filters, + id, + itemsPerPage, + kqlQuery, + sort, + show, + showCheckboxes, + showRowRenderers, + timelineById: state.timelineById, + }), + }) + ) .case(upsertColumn, (state, { column, id, index }) => ({ ...state, timelineById: upsertTimelineColumn({ column, id, index, timelineById: state.timelineById }), @@ -218,6 +249,65 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) }, }, })) + .case(setEventsDeleted, (state, { id, eventIds, isDeleted }) => ({ + ...state, + timelineById: setDeletedTimelineEvents({ + id, + eventIds, + timelineById: state.timelineById, + isDeleted, + }), + })) + .case(clearEventsDeleted, (state, { id }) => ({ + ...state, + timelineById: { + ...state.timelineById, + [id]: { + ...state.timelineById[id], + deletedEventIds: [], + }, + }, + })) + .case(setEventsLoading, (state, { id, eventIds, isLoading }) => ({ + ...state, + timelineById: setLoadingTimelineEvents({ + id, + eventIds, + timelineById: state.timelineById, + isLoading, + }), + })) + .case(clearEventsLoading, (state, { id }) => ({ + ...state, + timelineById: { + ...state.timelineById, + [id]: { + ...state.timelineById[id], + loadingEventIds: [], + }, + }, + })) + .case(setSelected, (state, { id, eventIds, isSelected, isSelectAllChecked }) => ({ + ...state, + timelineById: setSelectedTimelineEvents({ + id, + eventIds, + timelineById: state.timelineById, + isSelected, + isSelectAllChecked, + }), + })) + .case(clearSelected, (state, { id }) => ({ + ...state, + timelineById: { + ...state.timelineById, + [id]: { + ...state.timelineById[id], + selectedEventIds: {}, + isSelectAllChecked: false, + }, + }, + })) .case(updateIsLoading, (state, { id, isLoading }) => ({ ...state, timelineById: { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c21a00c6c149..6324459090e4 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10834,7 +10834,6 @@ "xpack.siem.eventsOverTime.showing": "表示中", "xpack.siem.eventsOverTime.unit": "{totalCount, plural, =1 {event} other {events}}", "xpack.siem.flyout.button.text": "タイムライン", - "xpack.siem.footer.loadingEventsData": "イベントを読み込み中", "xpack.siem.network.navigation.anomaliesTitle": "異常", "xpack.siem.network.navigation.dnsTitle": "DNS", "xpack.siem.network.navigation.flowsTitle": "Flow", @@ -12717,4 +12716,4 @@ "xpack.licensing.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText": "ライセンスを更新", "xpack.licensing.welcomeBanner.licenseIsExpiredTitle": "ご使用の {licenseType} ライセンスは期限切れです" } -} +} \ No newline at end of file diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 8e374fd084a8..4c7b86bf937b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10862,7 +10862,6 @@ "xpack.siem.eventsOverTime.showing": "显示", "xpack.siem.eventsOverTime.unit": "{totalCount, plural, =1 {event} other {events}}", "xpack.siem.flyout.button.text": "时间线", - "xpack.siem.footer.loadingEventsData": "正在加载事件", "xpack.siem.network.navigation.anomaliesTitle": "异常", "xpack.siem.network.navigation.dnsTitle": "DNS", "xpack.siem.network.navigation.flowsTitle": "Flows",