[SIEM] Adds actions and selection to Signals Table (#53101) (#53551)

## Summary

This is `Part II` of `II` for adding the `Signals Table` to the main Detection Engine landing page ([meta issue](https://github.com/elastic/kibana/issues/50405)).

`Part II` includes:
* Adding `selection`, `selectAll` & `selectAllGlobal` (i.e. query select) functionality to the EventsViewer
* Includes ability to specify a fieldset when storing selection state so it can be used by custom actions
 * Introduces following new Timeline state:
    * `deletedEventIds: string[]`
    * `loadingEventIds: string[]`
    *  `selectedEventIds: Record<string, TimelineNonEcsData[]>`
    * `showCheckboxes: boolean`
    * `showRowRenderers: boolean`
* Adds Send to Timeline overflow/batch action (detailed [here](https://github.com/elastic/kibana/issues/50405#issuecomment-565470830))
* Adds Update Signal Status overflow/batch action

Resolves https://github.com/elastic/kibana/issues/51785

##### Selection / Update Signal Status
![update_signal_state](https://user-images.githubusercontent.com/2946766/70887496-61d59280-1f9b-11ea-8483-ab30e3936738.gif)

##### Send Signal to Timeline Action

### Checklist

Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR.

- [ ] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility)
- [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)
- [ ] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials
- [ ] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios
- [ ] ~This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~

### For maintainers

- [ ] ~This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~
- [ ] ~This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~
This commit is contained in:
Garrett Spong 2019-12-18 22:27:01 -07:00 committed by GitHub
parent 1b6a87b8d7
commit 3b0e778c79
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 1869 additions and 330 deletions

View file

@ -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';

View file

@ -64,7 +64,7 @@ export const AlertsTable = React.memo(
const alertsFilter = useMemo(() => [...defaultAlertsFilters, ...pageFilters], [pageFilters]);
return (
<StatefulEventsViewer
defaultFilters={alertsFilter}
pageFilters={alertsFilter}
defaultModel={alertsDefaultModel}
end={endDate}
id={ALERTS_TABLE_ID}
@ -73,8 +73,6 @@ export const AlertsTable = React.memo(
() => ({
documentType: i18n.ALERTS_DOCUMENT_TYPE,
footerText: i18n.TOTAL_COUNT_OF_ALERTS,
showCheckboxes: false,
showRowRenderers: false,
title: i18n.ALERTS_TABLE_TITLE,
}),
[]

View file

@ -65,4 +65,5 @@ export const alertsHeaders: ColumnHeader[] = [
export const alertsDefaultModel: SubsetTimelineModel = {
...timelineDefaults,
columns: alertsHeaders,
showRowRenderers: false,
};

View file

@ -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<string[]>;
end: number;
filters: esFilters.Filter[];
headerFilterGroup?: React.ReactNode;
@ -71,6 +72,7 @@ export const EventsViewer = React.memo<Props>(
browserFields,
columns,
dataProviders,
deletedEventIds,
end,
filters,
headerFilterGroup,
@ -104,6 +106,14 @@ export const EventsViewer = React.memo<Props>(
end,
isEventViewer: true,
});
const queryFields = useMemo(
() =>
union(
columnsHeader.map(c => c.id),
timelineTypeContext.queryFields ?? []
),
[columnsHeader, timelineTypeContext.queryFields]
);
return (
<EuiPanel data-test-subj="events-viewer-panel" grow={false}>
@ -119,7 +129,7 @@ export const EventsViewer = React.memo<Props>(
{combinedQueries != null ? (
<TimelineQuery
fields={columnsHeader.map(c => c.id)}
fields={queryFields}
filterQuery={combinedQueries.filterQuery}
id={id}
indexPattern={indexPattern}
@ -139,73 +149,81 @@ export const EventsViewer = React.memo<Props>(
pageInfo,
refetch,
totalCount = 0,
}) => (
<>
<HeaderSection
id={id}
showInspect={showInspect}
subtitle={
utilityBar
? undefined
: `${i18n.SHOWING}: ${totalCount.toLocaleString()} ${i18n.UNIT(
totalCount
)}`
}
title={timelineTypeContext?.title ?? i18n.EVENTS}
>
{headerFilterGroup}
</HeaderSection>
}) => {
const totalCountMinusDeleted =
totalCount > 0 ? totalCount - deletedEventIds.length : 0;
{utilityBar?.(totalCount)}
<div
data-test-subj={`events-container-loading-${loading}`}
style={{ width: `${width}px` }}
>
<ManageTimelineContext
loading={loading}
width={width}
type={timelineTypeContext}
// TODO: Reset eventDeletedIds/eventLoadingIds on refresh/loadmore (getUpdatedAt)
return (
<>
<HeaderSection
id={id}
showInspect={showInspect}
subtitle={
utilityBar
? undefined
: `${
i18n.SHOWING
}: ${totalCountMinusDeleted.toLocaleString()} ${i18n.UNIT(
totalCountMinusDeleted
)}`
}
title={timelineTypeContext?.title ?? i18n.EVENTS}
>
<TimelineRefetch
id={id}
inputId="global"
inspect={inspect}
{headerFilterGroup}
</HeaderSection>
{utilityBar?.(totalCountMinusDeleted)}
<div
data-test-subj={`events-container-loading-${loading}`}
style={{ width: `${width}px` }}
>
<ManageTimelineContext
loading={loading}
refetch={refetch}
/>
width={width}
type={timelineTypeContext}
>
<TimelineRefetch
id={id}
inputId="global"
inspect={inspect}
loading={loading}
refetch={refetch}
/>
<StatefulBody
browserFields={browserFields}
data={events}
id={id}
isEventViewer={true}
height={height}
sort={sort}
toggleColumn={toggleColumn}
/>
<StatefulBody
browserFields={browserFields}
data={events.filter(e => !deletedEventIds.includes(e._id))}
id={id}
isEventViewer={true}
height={height}
sort={sort}
toggleColumn={toggleColumn}
/>
<Footer
compact={isCompactFooter(width)}
getUpdatedAt={getUpdatedAt}
hasNextPage={getOr(false, 'hasNextPage', pageInfo)!}
height={footerHeight}
isEventViewer={true}
isLive={isLive}
isLoading={loading}
itemsCount={events.length}
itemsPerPage={itemsPerPage}
itemsPerPageOptions={itemsPerPageOptions}
onChangeItemsPerPage={onChangeItemsPerPage}
onLoadMore={loadMore}
nextCursor={getOr(null, 'endCursor.value', pageInfo)!}
serverSideEventCount={totalCount}
tieBreaker={getOr(null, 'endCursor.tiebreaker', pageInfo)}
/>
</ManageTimelineContext>
</div>
</>
)}
<Footer
compact={isCompactFooter(width)}
getUpdatedAt={getUpdatedAt}
hasNextPage={getOr(false, 'hasNextPage', pageInfo)!}
height={footerHeight}
isEventViewer={true}
isLive={isLive}
isLoading={loading}
itemsCount={events.length}
itemsPerPage={itemsPerPage}
itemsPerPageOptions={itemsPerPageOptions}
onChangeItemsPerPage={onChangeItemsPerPage}
onLoadMore={loadMore}
nextCursor={getOr(null, 'endCursor.value', pageInfo)!}
serverSideEventCount={totalCountMinusDeleted}
tieBreaker={getOr(null, 'endCursor.tiebreaker', pageInfo)}
/>
</ManageTimelineContext>
</div>
</>
);
}}
</TimelineQuery>
) : null}
</>
@ -218,6 +236,7 @@ export const EventsViewer = React.memo<Props>(
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<Props>(
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';

View file

@ -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<string[]>;
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<Props>(
createTimeline,
columns,
dataProviders,
defaultFilters = [],
defaultModel,
deletedEventIds,
defaultIndices,
deleteEventQuery,
end,
@ -96,13 +100,15 @@ const StatefulEventsViewerComponent = React.memo<Props>(
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<Props>(
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<Props>(
const handleOnMouseEnter = useCallback(() => setShowInspect(true), []);
const handleOnMouseLeave = useCallback(() => setShowInspect(false), []);
const eventsFilter = useMemo(() => [...filters], [defaultFilters]);
return (
<div onMouseEnter={handleOnMouseEnter} onMouseLeave={handleOnMouseLeave}>
<EventsViewer
@ -159,8 +165,9 @@ const StatefulEventsViewerComponent = React.memo<Props>(
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<Props>(
},
(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<Props>(
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);

View file

@ -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 },

View file

@ -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',

View file

@ -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()}

View file

@ -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<string[]>;
noteIds: string[];
onEventToggled: () => void;
onPinClicked: () => void;
@ -39,6 +54,7 @@ const emptyNotes: string[] = [];
export const Actions = React.memo<Props>(
({
actionsColumnWidth,
additionalActions,
associateNote,
checked,
expanded,
@ -47,9 +63,11 @@ export const Actions = React.memo<Props>(
getNotesByIds,
isEventViewer = false,
loading = false,
loadingEventIds,
noteIds,
onEventToggled,
onPinClicked,
onRowSelected,
showCheckboxes,
showNotes,
toggleShowNotes,
@ -62,16 +80,27 @@ export const Actions = React.memo<Props>(
{showCheckboxes && (
<EventsTd data-test-subj="select-event-container">
<EventsTdContent textAlign="center">
<EuiCheckbox
data-test-subj="select-event"
id={eventId}
checked={checked}
onChange={noop}
/>
{loadingEventIds.includes(eventId) ? (
<EuiLoadingSpinner size="m" data-test-subj="event-loader" />
) : (
<EuiCheckbox
data-test-subj="select-event"
id={eventId}
checked={checked}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
onRowSelected({
eventIds: [eventId],
isSelected: event.currentTarget.checked,
});
}}
/>
)}
</EventsTdContent>
</EventsTd>
)}
<>{additionalActions}</>
<EventsTd>
<EventsTdContent textAlign="center">
{loading && <EventsLoading />}
@ -137,7 +166,9 @@ export const Actions = React.memo<Props>(
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
);

View file

@ -8,6 +8,7 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = `
<EventsThGroupActions
actionsColumnWidth={115}
data-test-subj="actions-container"
justifyContent="space-between"
>
<EventsTh>
<EventsThContent

View file

@ -41,11 +41,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()}
@ -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()}

View file

@ -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 = ({
<EventsTrHeader>
<EventsThGroupActions
actionsColumnWidth={actionsColumnWidth}
justifyContent={showSelectAllCheckbox ? 'flexStart' : 'space-between'}
data-test-subj="actions-container"
>
{showEventsSelect && (
@ -88,8 +97,23 @@ export const ColumnHeadersComponent = ({
</EventsTh>
)}
{showSelectAllCheckbox && (
<EventsTh>
<EventsThContent textAlign="center">
<EuiCheckbox
data-test-subj="select-all-events"
id={'select-all-events'}
checked={isSelectAllChecked}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
onSelectAll({ isSelected: event.currentTarget.checked });
}}
/>
</EventsThContent>
</EventsTh>
)}
<EventsTh>
<EventsThContent textAlign="center">
<EventsThContent textAlign={showSelectAllCheckbox ? 'left' : 'center'}>
<StatefulFieldsBrowser
browserFields={browserFields}
columnHeaders={columnHeaders}

View file

@ -4,14 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import * as React from 'react';
import React, { useMemo } from 'react';
import uuid from 'uuid';
import { TimelineNonEcsData } from '../../../../graphql/types';
import { Note } from '../../../../lib/note';
import { AssociateNote, UpdateNote } from '../../../notes/helpers';
import { OnColumnResized, OnPinEvent, OnUnPinEvent } from '../../events';
import { EventsTrData } from '../../styles';
import { OnColumnResized, OnPinEvent, OnRowSelected, OnUnPinEvent } from '../../events';
import { EventsTdContent, EventsTrData } from '../../styles';
import { Actions } from '../actions';
import { ColumnHeader } from '../column_headers/column_header';
import { DataDrivenColumns } from '../data_driven_columns';
@ -32,10 +32,14 @@ interface Props {
isEventPinned: boolean;
isEventViewer?: boolean;
loading: boolean;
loadingEventIds: Readonly<string[]>;
onColumnResized: OnColumnResized;
onEventToggled: () => void;
onPinEvent: OnPinEvent;
onRowSelected: OnRowSelected;
onUnPinEvent: OnUnPinEvent;
selectedEventIds: Readonly<Record<string, TimelineNonEcsData[]>>;
showCheckboxes: boolean;
showNotes: boolean;
timelineId: string;
toggleShowNotes: () => void;
@ -60,10 +64,14 @@ export const EventColumnView = React.memo<Props>(
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<Props>(
}) => {
const timelineTypeContext = useTimelineTypeContext();
const additionalActions = useMemo<JSX.Element[]>(() => {
return (
timelineTypeContext.timelineActions?.map(action => (
<EventsTdContent key={action.id} textAlign="center">
{action.getAction({ eventId: id, data })}
</EventsTdContent>
)) ?? []
);
}, [data, timelineTypeContext.timelineActions]);
return (
<EventsTrData data-test-subj="event-column-view">
<Actions
actionsColumnWidth={actionsColumnWidth}
additionalActions={additionalActions}
associateNote={associateNote}
checked={false}
checked={Object.keys(selectedEventIds).includes(id)}
onRowSelected={onRowSelected}
expanded={expanded}
data-test-subj="actions"
eventId={id}
@ -84,6 +104,7 @@ export const EventColumnView = React.memo<Props>(
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<Props>(
onUnPinEvent,
isEventPinned,
})}
showCheckboxes={timelineTypeContext.showCheckboxes}
showCheckboxes={showCheckboxes}
showNotes={showNotes}
toggleShowNotes={toggleShowNotes}
updateNote={updateNote}
@ -120,7 +141,11 @@ export const EventColumnView = React.memo<Props>(
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
);

View file

@ -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<string[]>;
onColumnResized: OnColumnResized;
onPinEvent: OnPinEvent;
onRowSelected: OnRowSelected;
onUpdateColumns: OnUpdateColumns;
onUnPinEvent: OnUnPinEvent;
pinnedEventIds: Readonly<Record<string, boolean>>;
rowRenderers: RowRenderer[];
selectedEventIds: Readonly<Record<string, TimelineNonEcsData[]>>;
showCheckboxes: boolean;
toggleColumn: (column: ColumnHeader) => void;
updateNote: UpdateNote;
}
@ -55,12 +65,16 @@ export const Events = React.memo<Props>(
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<Props>(
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}

View file

@ -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<Record<string, string[]>>;
getNotesByIds: (noteIds: string[]) => Note[];
isEventViewer?: boolean;
loadingEventIds: Readonly<string[]>;
maxDelay?: number;
onColumnResized: OnColumnResized;
onPinEvent: OnPinEvent;
onRowSelected: OnRowSelected;
onUnPinEvent: OnUnPinEvent;
onUpdateColumns: OnUpdateColumns;
isEventPinned: boolean;
rowRenderers: RowRenderer[];
selectedEventIds: Readonly<Record<string, TimelineNonEcsData[]>>;
showCheckboxes: boolean;
timelineId: string;
toggleColumn: (column: ColumnHeader) => void;
updateNote: UpdateNote;
@ -110,12 +120,16 @@ export const StatefulEvent = React.memo<Props>(
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<Props>(
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}

View file

@ -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<string[]>;
onColumnResized: OnColumnResized;
onRowSelected: OnRowSelected;
onUnPinEvent: OnUnPinEvent;
selectedEventIds: Readonly<Record<string, TimelineNonEcsData[]>>;
showCheckboxes: boolean;
showNotes: boolean;
timelineId: string;
updateNote: UpdateNote;
@ -62,9 +66,13 @@ export const StatefulEventChild = React.memo<Props>(
isEventViewer = false,
isEventPinned = false,
loading,
loadingEventIds,
onColumnResized,
onRowSelected,
onToggleExpanded,
onUnPinEvent,
selectedEventIds,
showCheckboxes,
showNotes,
timelineId,
onToggleShowNotes,
@ -90,10 +98,14 @@ export const StatefulEventChild = React.memo<Props>(
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}

View file

@ -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<string, TimelineNonEcsData[]> => {
return timelineData.reduce((acc, v) => {
const fvm = eventIds.includes(v._id)
? { [v._id]: v.data.filter(ti => fieldsToKeep.includes(ti.field)) }
: {};
return {
...acc,
...fvm,
};
}, {});
};

View file

@ -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()}
/>

View file

@ -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<Record<string, string[]>>;
loadingEventIds: Readonly<string[]>;
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<Record<string, boolean>>;
range: string;
rowRenderers: RowRenderer[];
selectedEventIds: Readonly<Record<string, TimelineNonEcsData[]>>;
showCheckboxes: boolean;
sort: Sort;
toggleColumn: (column: ColumnHeader) => void;
updateNote: UpdateNote;
@ -68,24 +76,38 @@ export const Body = React.memo<BodyProps>(
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<BodyProps>(
style={{ minWidth: columnWidths + 'px' }}
>
<ColumnHeaders
actionsColumnWidth={getActionsColumnWidth(
isEventViewer,
timelineTypeContext.showCheckboxes
)}
actionsColumnWidth={actionsColumnWidth}
browserFields={browserFields}
columnHeaders={columnHeaders}
isEventViewer={isEventViewer}
isSelectAllChecked={isSelectAllChecked}
onColumnRemoved={onColumnRemoved}
onColumnResized={onColumnResized}
onColumnSorted={onColumnSorted}
onFilterChange={onFilterChange}
onSelectAll={onSelectAll}
onUpdateColumns={onUpdateColumns}
showEventsSelect={false}
showSelectAllCheckbox={showCheckboxes}
sort={sort}
timelineId={id}
toggleColumn={toggleColumn}
/>
<Events
actionsColumnWidth={getActionsColumnWidth(
isEventViewer,
timelineTypeContext.showCheckboxes
)}
actionsColumnWidth={actionsColumnWidth}
addNoteToEvent={addNoteToEvent}
browserFields={browserFields}
columnHeaders={columnHeaders}
@ -129,12 +148,16 @@ export const Body = React.memo<BodyProps>(
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}
/>

View file

@ -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<Record<string, string[]>>;
isSelectAllChecked: boolean;
loadingEventIds: Readonly<string[]>;
notesById: appModel.NotesById;
pinnedEventIds: Readonly<Record<string, boolean>>;
range?: string;
selectedEventIds: Readonly<Record<string, TimelineNonEcsData[]>>;
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<string, TimelineNonEcsData[]>;
isSelected: boolean;
isSelectAllChecked: boolean;
}>;
unPinEvent?: ActionCreator<{
id: string;
eventId: string;
@ -97,11 +113,18 @@ const StatefulBodyComponent = React.memo<StatefulBodyComponentProps>(
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<StatefulBodyComponentProps>(
[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<StatefulBodyComponentProps>(
[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 (
<Body
addNoteToEvent={onAddNoteToEvent}
@ -162,16 +222,22 @@ const StatefulBodyComponent = React.memo<StatefulBodyComponentProps>(
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<StatefulBodyComponentProps>(
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,

View file

@ -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;

View file

@ -211,6 +211,7 @@ export const FooterComponent = ({
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [paginationLoading, setPaginationLoading] = useState(false);
const [updatedAt, setUpdatedAt] = useState<number | null>(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%"
/>
</LoadingPanelContainer>

View file

@ -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',
});

View file

@ -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';

View file

@ -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<boolean>(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<TimelineTypeContextProps>(initTimelineType);

View file

@ -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<unknown> => {
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();
};

View file

@ -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
}

View file

@ -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],

View file

@ -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(() => {
</EuiPanel>
<EuiSpacer />
<SignalsTable />
<GlobalTime>{({ to, from }) => <SignalsTable from={from} to={to} />}</GlobalTime>
</WrapperPage>
</StickyContainer>
) : (

View file

@ -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<string[]>) => {
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
});
};

View file

@ -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 (
<>
<UtilityBar>
<UtilityBarSection>
<UtilityBarGroup>
<UtilityBarText>{`${i18n.PANEL_SUBTITLE_SHOWING}: ${totalCount} signals`}</UtilityBarText>
</UtilityBarGroup>
</UtilityBarSection>
<UtilityBarSection>
<UtilityBarGroup>
<UtilityBarAction
iconSide="right"
iconType="arrowDown"
popoverContent={() => <p>{'Customize columns context menu here.'}</p>}
>
{'Customize columns'}
</UtilityBarAction>
<UtilityBarAction iconType="indexMapping">{'Aggregate data'}</UtilityBarAction>
</UtilityBarGroup>
</UtilityBarSection>
</UtilityBar>
{/* Michael: Closed signals datagrid here. Talk to Chandler Prall about possibility of early access. If not possible, use basic table. */}
</>
);
});

View file

@ -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 (
<>
<UtilityBar>
<UtilityBarSection>
<UtilityBarGroup>
<UtilityBarText>{`${i18n.PANEL_SUBTITLE_SHOWING}: ${totalCount} signals`}</UtilityBarText>
</UtilityBarGroup>
<UtilityBarGroup>
<UtilityBarText>{'Selected: 20 signals'}</UtilityBarText>
<UtilityBarAction
iconSide="right"
iconType="arrowDown"
popoverContent={() => <p>{'Batch actions context menu here.'}</p>}
>
{'Batch actions'}
</UtilityBarAction>
<UtilityBarAction iconType="listAdd">
{'Select all signals on all pages'}
</UtilityBarAction>
</UtilityBarGroup>
</UtilityBarSection>
</UtilityBar>
{/* Michael: Open signals datagrid here. Talk to Chandler Prall about possibility of early access. If not possible, use basic table. */}
</>
);
});

View file

@ -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 (
<EuiFilterGroup>
<EuiFilterButton
hasActiveFilters={filterGroup === FILTER_OPEN}
onClick={onClickOpenFilterCallback}
withNext
>
{i18n.OPEN_SIGNALS}
</EuiFilterButton>
<EuiFilterButton
hasActiveFilters={filterGroup === FILTER_CLOSED}
onClick={onClickCloseFilterCallback}
>
{i18n.CLOSED_SIGNALS}
</EuiFilterButton>
</EuiFilterGroup>
);
}
);

View file

@ -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<Record<string, TimelineNonEcsData[]>>,
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 [
<EuiContextMenuItem
key={i18n.BATCH_ACTION_VIEW_SELECTED_IN_TIMELINE}
icon="editorUnorderedList"
disabled={allDisabled || sendToTimelineDisabled}
onClick={async () => {
closePopover();
sendSignalsToTimeline();
}}
>
{i18n.BATCH_ACTION_VIEW_SELECTED_IN_TIMELINE}
</EuiContextMenuItem>,
<EuiContextMenuItem
key={filterString}
icon={isFilteredToOpen ? 'indexClose' : 'indexOpen'}
disabled={allDisabled}
onClick={async () => {
closePopover();
await updateSignalsStatus({
signalIds: Object.keys(selectedEventIds),
status: isFilteredToOpen ? FILTER_CLOSED : FILTER_OPEN,
});
}}
>
{filterString}
</EuiContextMenuItem>,
];
};
/**
* Returns the number of unique rules for a given list of signals
*
* @param signals
*/
export const uniqueRuleCount = (
signals: Readonly<Record<string, TimelineNonEcsData[]>>
): number => {
const ruleIds = Object.values(signals).flatMap(
data => data.find(d => d.field === 'signal.rule.id')?.value
);
return Array.from(new Set(ruleIds)).length;
};

View file

@ -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<Record<string, TimelineNonEcsData[]>>;
sendSignalsToTimeline: SendSignalsToTimeline;
showClearSelection: boolean;
totalCount: number;
updateSignalsStatus: UpdateSignalsStatus;
}
export const SignalsUtilityBar = React.memo<SignalsUtilityBarProps>(
({
areEventsLoading,
clearSelection,
totalCount,
selectedEventIds,
isFilteredToOpen,
selectAll,
showClearSelection,
updateSignalsStatus,
sendSignalsToTimeline,
}) => {
const [defaultNumberFormat] = useKibanaUiSetting(DEFAULT_NUMBER_FORMAT);
const getBatchItemsPopoverContent = useCallback(
(closePopover: () => void) => (
<EuiContextMenuPanel
items={getBatchItems(
areEventsLoading,
showClearSelection,
selectedEventIds,
updateSignalsStatus,
sendSignalsToTimeline,
closePopover,
isFilteredToOpen
)}
/>
),
[
areEventsLoading,
selectedEventIds,
updateSignalsStatus,
sendSignalsToTimeline,
isFilteredToOpen,
]
);
const formattedTotalCount = numeral(totalCount).format(defaultNumberFormat);
const formattedSelectedEventsCount = numeral(Object.keys(selectedEventIds).length).format(
defaultNumberFormat
);
return (
<>
<UtilityBar>
<UtilityBarSection>
<UtilityBarGroup>
<UtilityBarText>
{i18n.SHOWING_SIGNALS(formattedTotalCount, totalCount)}
</UtilityBarText>
</UtilityBarGroup>
<UtilityBarGroup>
{totalCount > 0 && (
<>
<UtilityBarText>
{i18n.SELECTED_SIGNALS(
showClearSelection ? formattedTotalCount : formattedSelectedEventsCount,
showClearSelection ? totalCount : Object.keys(selectedEventIds).length
)}
</UtilityBarText>
<UtilityBarAction
iconSide="right"
iconType="arrowDown"
popoverContent={getBatchItemsPopoverContent}
>
{i18n.BATCH_ACTIONS}
</UtilityBarAction>
<UtilityBarAction
iconType="listAdd"
onClick={() => {
if (!showClearSelection) {
selectAll();
} else {
clearSelection();
}
}}
>
{showClearSelection
? i18n.CLEAR_SELECTION
: i18n.SELECT_ALL_SIGNALS(formattedTotalCount, totalCount)}
</UtilityBarAction>
</>
)}
</UtilityBarGroup>
</UtilityBarSection>
</UtilityBar>
</>
);
},
(prevProps, nextProps) => {
return (
prevProps.selectedEventIds === nextProps.selectedEventIds &&
prevProps.totalCount === nextProps.totalCount &&
prevProps.showClearSelection === nextProps.showClearSelection
);
}
);

View file

@ -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',
}
);

View file

@ -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 => (
<EuiToolTip
data-test-subj="send-signal-to-timeline-tool-tip"
content={i18n.ACTION_VIEW_IN_TIMELINE}
>
<EuiButtonIcon
data-test-subj={'send-signal-to-timeline-tool-tip'}
onClick={() => sendSignalsToTimelineAction({ createTimeline, data: [data] })}
iconType="tableDensityNormal"
aria-label="Next"
/>
</EuiToolTip>
),
id: 'sendSignalToTimeline',
width: 26,
},
{
getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => (
<EuiToolTip
data-test-subj="update-signal-status-tool-tip"
content={status === FILTER_OPEN ? i18n.ACTION_OPEN_SIGNAL : i18n.ACTION_CLOSE_SIGNAL}
>
<EuiButtonIcon
data-test-subj={'update-signal-status-button'}
onClick={() =>
updateSignalStatusAction({
signalIds: [eventId],
status,
setEventsLoading,
setEventsDeleted,
kbnVersion,
})
}
iconType={status === FILTER_OPEN ? 'indexOpen' : 'indexClose'}
aria-label="Next"
/>
</EuiToolTip>
),
id: 'updateSignalStatus',
width: 26,
},
];

View file

@ -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<Record<string, TimelineNonEcsData[]>>;
}
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<SignalsTableComponentProps>(
({
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<SignalFilterOption>(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 (
<SignalsUtilityBar
areEventsLoading={loadingEventIds.length > 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 (
<EuiFilterGroup>
<EuiFilterButton
hasActiveFilters={filterGroup === FILTER_OPEN}
onClick={() => {
setFilterGroup(FILTER_OPEN);
onFilterGroupChanged(FILTER_OPEN);
}}
withNext
>
{'Open signals'}
</EuiFilterButton>
<EuiFilterButton
hasActiveFilters={filterGroup === FILTER_CLOSED}
onClick={() => {
setFilterGroup(FILTER_CLOSED);
onFilterGroupChanged(FILTER_CLOSED);
}}
>
{'Closed signals'}
</EuiFilterButton>
</EuiFilterGroup>
<StatefulEventsViewer
defaultIndices={defaultIndices} // TODO Get from new FrankInspired XavierHook
pageFilters={defaultFiltersMemo}
defaultModel={signalsDefaultModel}
end={to}
headerFilterGroup={
<SignalsTableFilterGroup onFilterGroupChanged={onFilterGroupChangedCallback} />
}
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 (
<>
<GlobalTime>
{({ to, from, setQuery, deleteQuery, isInitializing }) => (
<StatefulEventsViewer
defaultIndices={[`${DEFAULT_SIGNALS_INDEX}-default`]}
defaultFilters={filterGroup === FILTER_OPEN ? signalsOpenFilters : signalsClosedFilters}
defaultModel={signalsDefaultModel}
end={to}
headerFilterGroup={
<SignalsTableFilterGroup onFilterGroupChanged={onFilterGroupChangedCallback} />
}
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 ? (
<OpenSignals totalCount={totalCount} />
) : (
<ClosedSignals totalCount={totalCount} />
)
}
/>
)}
</GlobalTime>
</>
);
});
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);

View file

@ -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',
}
);

View file

@ -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;

View file

@ -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<Record<string, TimelineNonEcsData[]>>;
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');

View file

@ -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',

View file

@ -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<string, TimelineNonEcsData[]>;
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;

View file

@ -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<string, TimelineNonEcsData[]>;
/** 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<TimelineModel, 'filters'> = {
columns: defaultHeaders,
dataProviders: [],
deletedEventIds: [],
description: '',
eventIdToNoteIds: {},
highlightedDropAndProviderId: '',
@ -113,6 +132,7 @@ export const timelineDefaults: SubsetTimelineModel & Pick<TimelineModel, 'filter
filters: [],
isFavorite: false,
isLive: false,
isSelectAllChecked: false,
isLoading: false,
isSaving: false,
itemsPerPage: 25,
@ -122,6 +142,7 @@ export const timelineDefaults: SubsetTimelineModel & Pick<TimelineModel, 'filter
filterQuery: null,
filterQueryDraft: null,
},
loadingEventIds: [],
title: '',
noteIds: [],
pinnedEventIds: {},
@ -131,7 +152,10 @@ export const timelineDefaults: SubsetTimelineModel & Pick<TimelineModel, 'filter
end: 0,
},
savedObjectId: null,
selectedEventIds: {},
show: false,
showCheckboxes: false,
showRowRenderers: true,
sort: {
columnId: '@timestamp',
sortDirection: Direction.desc,

View file

@ -64,6 +64,7 @@ const timelineByIdMock: TimelineById = {
],
columns: [],
description: '',
deletedEventIds: [],
eventIdToNoteIds: {},
highlightedDropAndProviderId: '',
historyIds: [],
@ -71,11 +72,13 @@ const timelineByIdMock: TimelineById = {
savedObjectId: null,
isFavorite: false,
isLive: false,
isSelectAllChecked: false,
isLoading: false,
itemsPerPage: 25,
itemsPerPageOptions: [10, 25, 50],
kqlMode: 'filter',
kqlQuery: { filterQuery: null, filterQueryDraft: null },
loadingEventIds: [],
title: '',
noteIds: [],
pinnedEventIds: {},
@ -84,7 +87,10 @@ const timelineByIdMock: TimelineById = {
start: 0,
end: 0,
},
selectedEventIds: {},
show: true,
showCheckboxes: false,
showRowRenderers: true,
sort: {
columnId: '@timestamp',
sortDirection: Direction.desc,
@ -1087,22 +1093,28 @@ describe('Timeline', () => {
},
},
],
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,

View file

@ -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: {

View file

@ -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} ライセンスは期限切れです"
}
}
}

View file

@ -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",