[SIEM] Fixes duplicate columns in timeline and fixes errors on multiple click on pinned event (#44010) (#44271)

* fix duplicate columns + error on multiple click on the pinned event

* fix test

* fix unpinned events

* review + put back debounce for better experience

* fix circulat dependency

* review optional
This commit is contained in:
Xavier Mouligneau 2019-08-28 17:20:25 -04:00 committed by GitHub
parent c33cf805e4
commit 6d3e3eb2bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 380 additions and 91 deletions

View file

@ -418,7 +418,10 @@ export class StatefulOpenTimelineComponent extends React.PureComponent<
? {}
: timelineModel.pinnedEventsSaveObject != null
? timelineModel.pinnedEventsSaveObject.reduce(
(acc, pinnedEvent) => ({ ...acc, [pinnedEvent.pinnedEventId]: pinnedEvent }),
(acc, pinnedEvent) => ({
...acc,
...(pinnedEvent.eventId != null ? { [pinnedEvent.eventId]: pinnedEvent } : {}),
}),
{}
)
: {},

View file

@ -3,7 +3,6 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mount } from 'enzyme';
import * as React from 'react';
@ -124,39 +123,6 @@ describe('Actions', () => {
expect(onEventToggled).toBeCalled();
});
test('it invokes onPinClicked when the button for pinning events is clicked', () => {
const onPinClicked = jest.fn();
const wrapper = mount(
<TestProviders>
<Actions
actionsColumnWidth={ACTIONS_COLUMN_WIDTH}
associateNote={jest.fn()}
checked={false}
expanded={false}
eventId="abc"
eventIsPinned={false}
getNotesByIds={jest.fn()}
loading={false}
noteIds={[]}
onEventToggled={jest.fn()}
onPinClicked={onPinClicked}
showCheckboxes={false}
showNotes={false}
toggleShowNotes={jest.fn()}
updateNote={jest.fn()}
/>
</TestProviders>
);
wrapper
.find('[data-test-subj="pin-event"]')
.first()
.simulate('click');
expect(onPinClicked).toBeCalled();
});
test('it invokes toggleShowNotes when the button for adding notes is clicked', () => {
const toggleShowNotes = jest.fn();
@ -189,4 +155,37 @@ describe('Actions', () => {
expect(toggleShowNotes).toBeCalled();
});
test('it invokes onPinClicked when the button for pinning events is clicked', () => {
const onPinClicked = jest.fn();
const wrapper = mount(
<TestProviders>
<Actions
actionsColumnWidth={ACTIONS_COLUMN_WIDTH}
associateNote={jest.fn()}
checked={false}
expanded={false}
eventId="abc"
eventIsPinned={false}
getNotesByIds={jest.fn()}
loading={false}
noteIds={[]}
onEventToggled={jest.fn()}
onPinClicked={onPinClicked}
showCheckboxes={false}
showNotes={false}
toggleShowNotes={jest.fn()}
updateNote={jest.fn()}
/>
</TestProviders>
);
wrapper
.find('[data-test-subj="pin"]')
.first()
.simulate('click');
expect(onPinClicked).toHaveBeenCalled();
});
});

View file

@ -3,7 +3,6 @@
* 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,

View file

@ -66,7 +66,7 @@ import { isNotNull } from './helpers';
import { dispatcherTimelinePersistQueue } from './epic_dispatcher_timeline_persistence_queue';
import { refetchQueries } from './refetch_queries';
import { myEpicTimelineId } from './my_epic_timeline_id';
import { TimelineById } from './types';
import { ActionTimeline, TimelineById } from './types';
interface TimelineEpicDependencies<State> {
timelineByIdSelector: (state: State) => TimelineById;
@ -75,15 +75,6 @@ interface TimelineEpicDependencies<State> {
apolloClient$: Observable<AppApolloClient>;
}
export interface ActionTimeline {
type: string;
payload: {
id: string;
eventId: string;
noteId: string;
};
}
const timelineActionsType = [
applyKqlFilterQuery.type,
addProvider.type,
@ -158,7 +149,7 @@ export const createTimelineEpic = <State>(): Epic<
delay(500),
withLatestFrom(timeline$, apolloClient$, notes$, timelineTimeRange$),
concatMap(([objAction, timeline, apolloClient, notes, timelineTimeRange]) => {
const action: Action = get('action', objAction);
const action: ActionTimeline = get('action', objAction);
const timelineId = myEpicTimelineId.getTimelineId();
const version = myEpicTimelineId.getTimelineVersion();
@ -179,28 +170,25 @@ export const createTimelineEpic = <State>(): Epic<
variables: {
timelineId,
version,
timeline: convertTimelineAsInput(
timeline[get('payload.id', action)],
timelineTimeRange
),
timeline: convertTimelineAsInput(timeline[action.payload.id], timelineTimeRange),
},
refetchQueries,
})
).pipe(
withLatestFrom(timeline$),
mergeMap(([result, recentTimeline]) => {
const savedTimeline = recentTimeline[get('payload.id', action)];
const savedTimeline = recentTimeline[action.payload.id];
const response: ResponseTimeline = get('data.persistTimeline', result);
const callOutMsg = response.code === 403 ? [showCallOutUnauthorizedMsg()] : [];
return [
response.code === 409
? updateAutoSaveMsg({
timelineId: get('payload.id', action),
timelineId: action.payload.id,
newTimelineModel: omitTypenameInTimeline(savedTimeline, response.timeline),
})
: updateTimeline({
id: get('payload.id', action),
id: action.payload.id,
timeline: {
...savedTimeline,
savedObjectId: response.timeline.savedObjectId,
@ -210,11 +198,11 @@ export const createTimelineEpic = <State>(): Epic<
}),
...callOutMsg,
endTimelineSaving({
id: get('payload.id', action),
id: action.payload.id,
}),
];
}),
startWith(startTimelineSaving({ id: get('payload.id', action) })),
startWith(startTimelineSaving({ id: action.payload.id })),
takeUntil(
action$.pipe(
withLatestFrom(timeline$),

View file

@ -25,13 +25,13 @@ import {
import { dispatcherTimelinePersistQueue } from './epic_dispatcher_timeline_persistence_queue';
import { refetchQueries } from './refetch_queries';
import { myEpicTimelineId } from './my_epic_timeline_id';
import { TimelineById } from './types';
import { ActionTimeline, TimelineById } from './types';
export const timelineFavoriteActionsType = [updateIsFavorite.type];
export const epicPersistTimelineFavorite = (
apolloClient: ApolloClient<NormalizedCacheObject>,
action: Action,
action: ActionTimeline,
timeline: TimelineById,
action$: Observable<Action>,
timeline$: Observable<TimelineById>
@ -52,14 +52,14 @@ export const epicPersistTimelineFavorite = (
).pipe(
withLatestFrom(timeline$),
mergeMap(([result, recentTimelines]) => {
const savedTimeline = recentTimelines[get('payload.id', action)];
const savedTimeline = recentTimelines[action.payload.id];
const response: ResponseFavoriteTimeline = get('data.persistFavorite', result);
const callOutMsg = response.code === 403 ? [showCallOutUnauthorizedMsg()] : [];
return [
...callOutMsg,
updateTimeline({
id: get('payload.id', action),
id: action.payload.id,
timeline: {
...savedTimeline,
isFavorite: response.favorite != null && response.favorite.length > 0,
@ -68,11 +68,11 @@ export const epicPersistTimelineFavorite = (
},
}),
endTimelineSaving({
id: get('payload.id', action),
id: action.payload.id,
}),
];
}),
startWith(startTimelineSaving({ id: get('payload.id', action) })),
startWith(startTimelineSaving({ id: action.payload.id })),
takeUntil(
action$.pipe(
withLatestFrom(timeline$),

View file

@ -28,12 +28,13 @@ import {
import { myEpicTimelineId } from './my_epic_timeline_id';
import { refetchQueries } from './refetch_queries';
import { dispatcherTimelinePersistQueue } from './epic_dispatcher_timeline_persistence_queue';
import { TimelineById } from './types';
import { ActionTimeline, TimelineById } from './types';
export const timelineNoteActionsType = [addNote.type, addNoteToEvent.type];
export const epicPersistNote = (
apolloClient: ApolloClient<NormalizedCacheObject>,
action: Action,
action: ActionTimeline,
timeline: TimelineById,
notes: NotesById,
action$: Observable<Action>,
@ -52,8 +53,8 @@ export const epicPersistNote = (
noteId: null,
version: null,
note: {
eventId: get('payload.eventId', action),
note: getNote(get('payload.noteId', action), notes),
eventId: action.payload.eventId,
note: getNote(action.payload.noteId, notes),
timelineId: myEpicTimelineId.getTimelineId(),
},
},
@ -62,17 +63,17 @@ export const epicPersistNote = (
).pipe(
withLatestFrom(timeline$, notes$),
mergeMap(([result, recentTimeline, recentNotes]) => {
const noteIdRedux = get('payload.noteId', action);
const noteIdRedux = action.payload.noteId;
const response: ResponseNote = get('data.persistNote', result);
const callOutMsg = response.code === 403 ? [showCallOutUnauthorizedMsg()] : [];
return [
...callOutMsg,
recentTimeline[get('payload.id', action)].savedObjectId == null
recentTimeline[action.payload.id].savedObjectId == null
? updateTimeline({
id: get('payload.id', action),
id: action.payload.id,
timeline: {
...recentTimeline[get('payload.id', action)],
...recentTimeline[action.payload.id],
savedObjectId: response.note.timelineId || null,
version: response.note.timelineVersion || null,
},
@ -94,11 +95,11 @@ export const epicPersistNote = (
},
}),
endTimelineSaving({
id: get('payload.id', action),
id: action.payload.id,
}),
].filter(item => item != null);
}),
startWith(startTimelineSaving({ id: get('payload.id', action) })),
startWith(startTimelineSaving({ id: action.payload.id })),
takeUntil(
action$.pipe(
withLatestFrom(timeline$),

View file

@ -26,13 +26,13 @@ import {
import { myEpicTimelineId } from './my_epic_timeline_id';
import { refetchQueries } from './refetch_queries';
import { dispatcherTimelinePersistQueue } from './epic_dispatcher_timeline_persistence_queue';
import { TimelineById } from './types';
import { ActionTimeline, TimelineById } from './types';
export const timelinePinnedEventActionsType = [pinEvent.type, unPinEvent.type];
export const epicPersistPinnedEvent = (
apolloClient: ApolloClient<NormalizedCacheObject>,
action: Action,
action: ActionTimeline,
timeline: TimelineById,
action$: Observable<Action>,
timeline$: Observable<TimelineById>
@ -47,14 +47,11 @@ export const epicPersistPinnedEvent = (
fetchPolicy: 'no-cache',
variables: {
pinnedEventId:
timeline[get('payload.id', action)].pinnedEventsSaveObject[
get('payload.eventId', action)
] != null
? timeline[get('payload.id', action)].pinnedEventsSaveObject[
get('payload.eventId', action)
].pinnedEventId
timeline[action.payload.id].pinnedEventsSaveObject[action.payload.eventId] != null
? timeline[action.payload.id].pinnedEventsSaveObject[action.payload.eventId]
.pinnedEventId
: null,
eventId: get('payload.eventId', action),
eventId: action.payload.eventId,
timelineId: myEpicTimelineId.getTimelineId(),
},
refetchQueries,
@ -62,14 +59,14 @@ export const epicPersistPinnedEvent = (
).pipe(
withLatestFrom(timeline$),
mergeMap(([result, recentTimeline]) => {
const savedTimeline = recentTimeline[get('payload.id', action)];
const savedTimeline = recentTimeline[action.payload.id];
const response: PinnedEvent = get('data.persistPinnedEventOnTimeline', result);
const callOutMsg = response && response.code === 403 ? [showCallOutUnauthorizedMsg()] : [];
return [
response != null
? updateTimeline({
id: get('payload.id', action),
id: action.payload.id,
timeline: {
...savedTimeline,
savedObjectId:
@ -80,29 +77,34 @@ export const epicPersistPinnedEvent = (
savedTimeline.version == null && response.timelineVersion != null
? response.timelineVersion
: savedTimeline.version,
pinnedEventIds: {
...savedTimeline.pinnedEventIds,
[action.payload.eventId]: true,
},
pinnedEventsSaveObject: {
...savedTimeline.pinnedEventsSaveObject,
[get('payload.eventId', action)]: response,
[action.payload.eventId]: response,
},
},
})
: updateTimeline({
id: get('payload.id', action),
id: action.payload.id,
timeline: {
...savedTimeline,
pinnedEventIds: omit(action.payload.eventId, savedTimeline.pinnedEventIds),
pinnedEventsSaveObject: omit(
get('payload.eventId', action),
action.payload.eventId,
savedTimeline.pinnedEventsSaveObject
),
},
}),
...callOutMsg,
endTimelineSaving({
id: get('payload.id', action),
id: action.payload.id,
}),
];
}),
startWith(startTimelineSaving({ id: get('payload.id', action) })),
startWith(startTimelineSaving({ id: action.payload.id })),
takeUntil(
action$.pipe(
withLatestFrom(timeline$),

View file

@ -0,0 +1,270 @@
/*
* 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 { addTimelineToStore } from './helpers';
import { TimelineResult } from '../../graphql/types';
import { timelineDefaults } from './model';
describe('helpers', () => {
describe('#addTimelineToStore', () => {
test('if title is null, we should get the default title', () => {
const timeline: TimelineResult = {
savedObjectId: 'savedObject-1',
title: null,
version: '1',
};
const newTimeline = addTimelineToStore({ id: 'timeline-1', timeline });
expect(newTimeline).toEqual({
'timeline-1': {
columns: [
{
columnHeaderType: 'not-filtered',
id: '@timestamp',
width: 240,
},
{
columnHeaderType: 'not-filtered',
id: 'message',
width: 180,
},
{
columnHeaderType: 'not-filtered',
id: 'event.category',
width: 180,
},
{
columnHeaderType: 'not-filtered',
id: 'event.action',
width: 180,
},
{
columnHeaderType: 'not-filtered',
id: 'host.name',
width: 180,
},
{
columnHeaderType: 'not-filtered',
id: 'source.ip',
width: 180,
},
{
columnHeaderType: 'not-filtered',
id: 'destination.ip',
width: 180,
},
{
columnHeaderType: 'not-filtered',
id: 'user.name',
width: 180,
},
],
dataProviders: [],
dateRange: {
end: 0,
start: 0,
},
description: '',
eventIdToNoteIds: {},
highlightedDropAndProviderId: '',
historyIds: [],
id: 'savedObject-1',
isFavorite: false,
isLive: false,
isLoading: false,
isSaving: false,
itemsPerPage: 25,
itemsPerPageOptions: [10, 25, 50, 100],
kqlMode: 'filter',
kqlQuery: {
filterQuery: null,
filterQueryDraft: null,
},
noteIds: [],
pinnedEventIds: {},
pinnedEventsSaveObject: {},
savedObjectId: 'savedObject-1',
show: true,
sort: {
columnId: '@timestamp',
sortDirection: 'desc',
},
title: '',
version: '1',
width: 1100,
},
});
});
test('if columns are null, we should get the default columns', () => {
const timeline: TimelineResult = {
savedObjectId: 'savedObject-1',
columns: null,
version: '1',
};
const newTimeline = addTimelineToStore({ id: 'timeline-1', timeline });
expect(newTimeline).toEqual({
'timeline-1': {
columns: [
{
columnHeaderType: 'not-filtered',
id: '@timestamp',
width: 240,
},
{
columnHeaderType: 'not-filtered',
id: 'message',
width: 180,
},
{
columnHeaderType: 'not-filtered',
id: 'event.category',
width: 180,
},
{
columnHeaderType: 'not-filtered',
id: 'event.action',
width: 180,
},
{
columnHeaderType: 'not-filtered',
id: 'host.name',
width: 180,
},
{
columnHeaderType: 'not-filtered',
id: 'source.ip',
width: 180,
},
{
columnHeaderType: 'not-filtered',
id: 'destination.ip',
width: 180,
},
{
columnHeaderType: 'not-filtered',
id: 'user.name',
width: 180,
},
],
dataProviders: [],
dateRange: {
end: 0,
start: 0,
},
description: '',
eventIdToNoteIds: {},
highlightedDropAndProviderId: '',
historyIds: [],
id: 'savedObject-1',
isFavorite: false,
isLive: false,
isLoading: false,
isSaving: false,
itemsPerPage: 25,
itemsPerPageOptions: [10, 25, 50, 100],
kqlMode: 'filter',
kqlQuery: {
filterQuery: null,
filterQueryDraft: null,
},
noteIds: [],
pinnedEventIds: {},
pinnedEventsSaveObject: {},
savedObjectId: 'savedObject-1',
show: true,
sort: {
columnId: '@timestamp',
sortDirection: 'desc',
},
title: '',
version: '1',
width: 1100,
},
});
});
test('should merge columns when event.action is deleted without two extra column names of user.name', () => {
const timeline: TimelineResult = {
savedObjectId: 'savedObject-1',
columns: timelineDefaults.columns.filter(column => column.id !== 'event.action'),
version: '1',
};
const newTimeline = addTimelineToStore({ id: 'timeline-1', timeline });
expect(newTimeline).toEqual({
'timeline-1': {
savedObjectId: 'savedObject-1',
columns: [
{
columnHeaderType: 'not-filtered',
id: '@timestamp',
width: 240,
},
{
columnHeaderType: 'not-filtered',
id: 'message',
width: 180,
},
{
columnHeaderType: 'not-filtered',
id: 'event.category',
width: 180,
},
{
columnHeaderType: 'not-filtered',
id: 'host.name',
width: 180,
},
{
columnHeaderType: 'not-filtered',
id: 'source.ip',
width: 180,
},
{
columnHeaderType: 'not-filtered',
id: 'destination.ip',
width: 180,
},
{
columnHeaderType: 'not-filtered',
id: 'user.name',
width: 180,
},
],
version: '1',
dataProviders: [],
description: '',
eventIdToNoteIds: {},
highlightedDropAndProviderId: '',
historyIds: [],
isFavorite: false,
isLive: false,
isLoading: false,
isSaving: false,
itemsPerPage: 25,
itemsPerPageOptions: [10, 25, 50, 100],
kqlMode: 'filter',
kqlQuery: {
filterQuery: null,
filterQueryDraft: null,
},
title: '',
noteIds: [],
pinnedEventIds: {},
pinnedEventsSaveObject: {},
dateRange: {
start: 0,
end: 0,
},
show: true,
sort: {
columnId: '@timestamp',
sortDirection: 'desc',
},
width: 1100,
id: 'savedObject-1',
},
});
});
});
});

View file

@ -3,7 +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 { getOr, omit, uniq, isEmpty, isEqualWith, defaultsDeep, pickBy, isNil } from 'lodash/fp';
import { getOr, omit, uniq, isEmpty, isEqualWith, set } from 'lodash/fp';
import { ColumnHeader } from '../../components/timeline/body/column_headers/column_header';
import { getColumnWidthFromType } from '../../components/timeline/body/helpers';
@ -109,6 +109,18 @@ interface AddTimelineParams {
timeline: TimelineResult;
}
const mergeTimeline = (timeline: TimelineResult): TimelineModel => {
return Object.entries(timeline).reduce(
(acc: TimelineModel, [key, value]) => {
if (value != null) {
acc = set(key, value, acc);
}
return acc;
},
{ ...timelineDefaults, id: '' }
);
};
/**
* Add a saved object timeline to the store
* and default the value to what need to be if values are null
@ -116,7 +128,7 @@ interface AddTimelineParams {
export const addTimelineToStore = ({ id, timeline }: AddTimelineParams): TimelineById => ({
// TODO: revisit this when we support multiple timelines
[id]: {
...defaultsDeep(timelineDefaults, pickBy(v => !isNil(v), timeline)),
...mergeTimeline(timeline),
id: timeline.savedObjectId || '',
show: true,
},

View file

@ -3,7 +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 { Action } from 'redux';
import { TimelineModel } from './model';
export interface AutoSavedWarningMsg {
@ -24,3 +24,11 @@ export interface TimelineState {
autoSavedWarningMsg: AutoSavedWarningMsg;
showCallOutUnauthorizedMsg: boolean;
}
export interface ActionTimeline extends Action<string> {
payload: {
id: string;
eventId: string;
noteId: string;
};
}

View file

@ -148,6 +148,13 @@ export class PinnedEvent {
await this.deletePinnedEventOnTimeline(request, [pinnedEventId]);
return null;
} catch (err) {
if (getOr(null, 'output.statusCode', err) === 404) {
/*
* Why we are doing that, because if it is not found for sure that it will be unpinned
* There is no need to bring back this error since we can assume that it is unpinned
*/
return null;
}
if (getOr(null, 'output.statusCode', err) === 403) {
return pinnedEventId != null
? {