[SECURITY SOLUTIONS] Keep context of timeline when switching tabs in security solutions (#82237)
* try to keep timeline context when switching tabs * fix popover * simpler solution to keep timelien context between tabs * fix timeline context with relative date * allow update on the kql bar when opening new timeline * keep detail view in context when savedObjectId of the timeline does not chnage * remove redux solution and just KISS it * add unit test for the popover * add test on timeline context cache * final commit -> to fix context of timeline between tabs * keep timerange kind to absolute when refreshing * fix bug today/thiw week to be absolute and not relative * add unit test for absolute date for today and this week * fix absolute today/this week on timeline * fix refresh between page and timeline when link * clean up * remove nit Co-authored-by: Patryk Kopycinski <contact@patrykkopycinski.com>
This commit is contained in:
parent
8cdf56636a
commit
f3599fec4c
|
@ -62,7 +62,10 @@ export const QueryBar = memo<QueryBarComponentProps>(
|
|||
const [draftQuery, setDraftQuery] = useState(filterQuery);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset draftQuery when `Create new timeline` is clicked
|
||||
setDraftQuery(filterQuery);
|
||||
}, [filterQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filterQueryDraft == null) {
|
||||
setDraftQuery(filterQuery);
|
||||
}
|
||||
|
|
|
@ -132,7 +132,7 @@ export const SearchBarComponent = memo<SiemSearchBarProps & PropsFromRedux>(
|
|||
|
||||
if (!isStateUpdated) {
|
||||
// That mean we are doing a refresh!
|
||||
if (isQuickSelection) {
|
||||
if (isQuickSelection && payload.dateRange.to !== payload.dateRange.from) {
|
||||
updateSearchBar.updateTime = true;
|
||||
updateSearchBar.end = payload.dateRange.to;
|
||||
updateSearchBar.start = payload.dateRange.from;
|
||||
|
@ -313,7 +313,7 @@ const makeMapStateToProps = () => {
|
|||
fromStr: getFromStrSelector(inputsRange),
|
||||
filterQuery: getFilterQuerySelector(inputsRange),
|
||||
isLoading: getIsLoadingSelector(inputsRange),
|
||||
queries: getQueriesSelector(inputsRange),
|
||||
queries: getQueriesSelector(state, id),
|
||||
savedQuery: getSavedQuerySelector(inputsRange),
|
||||
start: getStartSelector(inputsRange),
|
||||
toStr: getToStrSelector(inputsRange),
|
||||
|
@ -351,15 +351,27 @@ export const dispatchUpdateSearch = (dispatch: Dispatch) => ({
|
|||
const fromDate = formatDate(start);
|
||||
let toDate = formatDate(end, { roundUp: true });
|
||||
if (isQuickSelection) {
|
||||
dispatch(
|
||||
inputsActions.setRelativeRangeDatePicker({
|
||||
id,
|
||||
fromStr: start,
|
||||
toStr: end,
|
||||
from: fromDate,
|
||||
to: toDate,
|
||||
})
|
||||
);
|
||||
if (end === start) {
|
||||
dispatch(
|
||||
inputsActions.setAbsoluteRangeDatePicker({
|
||||
id,
|
||||
fromStr: start,
|
||||
toStr: end,
|
||||
from: fromDate,
|
||||
to: toDate,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
inputsActions.setRelativeRangeDatePicker({
|
||||
id,
|
||||
fromStr: start,
|
||||
toStr: end,
|
||||
from: fromDate,
|
||||
to: toDate,
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toDate = formatDate(end);
|
||||
dispatch(
|
||||
|
|
|
@ -139,7 +139,7 @@ describe('SIEM Super Date Picker', () => {
|
|||
expect(store.getState().inputs.global.timerange.toStr).toBe('now');
|
||||
});
|
||||
|
||||
test('Make Sure it is Today date', () => {
|
||||
test('Make Sure it is Today date is an absolute date', () => {
|
||||
wrapper
|
||||
.find('[data-test-subj="superDatePickerToggleQuickMenuButton"]')
|
||||
.first()
|
||||
|
@ -151,8 +151,22 @@ describe('SIEM Super Date Picker', () => {
|
|||
.first()
|
||||
.simulate('click');
|
||||
wrapper.update();
|
||||
expect(store.getState().inputs.global.timerange.fromStr).toBe('now/d');
|
||||
expect(store.getState().inputs.global.timerange.toStr).toBe('now/d');
|
||||
expect(store.getState().inputs.global.timerange.kind).toBe('absolute');
|
||||
});
|
||||
|
||||
test('Make Sure it is This Week date is an absolute date', () => {
|
||||
wrapper
|
||||
.find('[data-test-subj="superDatePickerToggleQuickMenuButton"]')
|
||||
.first()
|
||||
.simulate('click');
|
||||
wrapper.update();
|
||||
|
||||
wrapper
|
||||
.find('[data-test-subj="superDatePickerCommonlyUsed_This_week"]')
|
||||
.first()
|
||||
.simulate('click');
|
||||
wrapper.update();
|
||||
expect(store.getState().inputs.global.timerange.kind).toBe('absolute');
|
||||
});
|
||||
|
||||
test('Make Sure to (end date) is superior than from (start date)', () => {
|
||||
|
|
|
@ -91,12 +91,12 @@ export const SuperDatePickerComponent = React.memo<SuperDatePickerProps>(
|
|||
toStr,
|
||||
updateReduxTime,
|
||||
}) => {
|
||||
const [isQuickSelection, setIsQuickSelection] = useState(true);
|
||||
const [recentlyUsedRanges, setRecentlyUsedRanges] = useState<EuiSuperDatePickerRecentRange[]>(
|
||||
[]
|
||||
);
|
||||
const onRefresh = useCallback(
|
||||
({ start: newStart, end: newEnd }: OnRefreshProps): void => {
|
||||
const isQuickSelection = newStart.includes('now') || newEnd.includes('now');
|
||||
const { kqlHaveBeenUpdated } = updateReduxTime({
|
||||
end: newEnd,
|
||||
id,
|
||||
|
@ -117,12 +117,13 @@ export const SuperDatePickerComponent = React.memo<SuperDatePickerProps>(
|
|||
refetchQuery(queries);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[end, id, isQuickSelection, kqlQuery, start, timelineId]
|
||||
[end, id, kqlQuery, queries, start, timelineId, updateReduxTime]
|
||||
);
|
||||
|
||||
const onRefreshChange = useCallback(
|
||||
({ isPaused, refreshInterval }: OnRefreshChangeProps): void => {
|
||||
const isQuickSelection =
|
||||
(fromStr != null && fromStr.includes('now')) || (toStr != null && toStr.includes('now'));
|
||||
if (duration !== refreshInterval) {
|
||||
setDuration({ id, duration: refreshInterval });
|
||||
}
|
||||
|
@ -137,27 +138,22 @@ export const SuperDatePickerComponent = React.memo<SuperDatePickerProps>(
|
|||
refetchQuery(queries);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[id, isQuickSelection, duration, policy, toStr]
|
||||
[fromStr, toStr, duration, policy, setDuration, id, stopAutoReload, startAutoReload, queries]
|
||||
);
|
||||
|
||||
const refetchQuery = (newQueries: inputsModel.GlobalGraphqlQuery[]) => {
|
||||
const refetchQuery = (newQueries: inputsModel.GlobalQuery[]) => {
|
||||
newQueries.forEach((q) => q.refetch && (q.refetch as inputsModel.Refetch)());
|
||||
};
|
||||
|
||||
const onTimeChange = useCallback(
|
||||
({
|
||||
start: newStart,
|
||||
end: newEnd,
|
||||
isQuickSelection: newIsQuickSelection,
|
||||
isInvalid,
|
||||
}: OnTimeChangeProps) => {
|
||||
({ start: newStart, end: newEnd, isInvalid }: OnTimeChangeProps) => {
|
||||
const isQuickSelection = newStart.includes('now') || newEnd.includes('now');
|
||||
if (!isInvalid) {
|
||||
updateReduxTime({
|
||||
end: newEnd,
|
||||
id,
|
||||
isInvalid,
|
||||
isQuickSelection: newIsQuickSelection,
|
||||
isQuickSelection,
|
||||
kql: kqlQuery,
|
||||
start: newStart,
|
||||
timelineId,
|
||||
|
@ -174,15 +170,13 @@ export const SuperDatePickerComponent = React.memo<SuperDatePickerProps>(
|
|||
];
|
||||
|
||||
setRecentlyUsedRanges(newRecentlyUsedRanges);
|
||||
setIsQuickSelection(newIsQuickSelection);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[recentlyUsedRanges, kqlQuery]
|
||||
[updateReduxTime, id, kqlQuery, timelineId, recentlyUsedRanges]
|
||||
);
|
||||
|
||||
const endDate = kind === 'relative' ? toStr : new Date(end).toISOString();
|
||||
const startDate = kind === 'relative' ? fromStr : new Date(start).toISOString();
|
||||
const endDate = toStr != null ? toStr : new Date(end).toISOString();
|
||||
const startDate = fromStr != null ? fromStr : new Date(start).toISOString();
|
||||
|
||||
const [quickRanges] = useUiSetting$<Range[]>(DEFAULT_TIMEPICKER_QUICK_RANGES);
|
||||
const commonlyUsedRanges = isEmpty(quickRanges)
|
||||
|
@ -232,15 +226,27 @@ export const dispatchUpdateReduxTime = (dispatch: Dispatch) => ({
|
|||
const fromDate = formatDate(start);
|
||||
let toDate = formatDate(end, { roundUp: true });
|
||||
if (isQuickSelection) {
|
||||
dispatch(
|
||||
inputsActions.setRelativeRangeDatePicker({
|
||||
id,
|
||||
fromStr: start,
|
||||
toStr: end,
|
||||
from: fromDate,
|
||||
to: toDate,
|
||||
})
|
||||
);
|
||||
if (end === start) {
|
||||
dispatch(
|
||||
inputsActions.setAbsoluteRangeDatePicker({
|
||||
id,
|
||||
fromStr: start,
|
||||
toStr: end,
|
||||
from: fromDate,
|
||||
to: toDate,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
inputsActions.setRelativeRangeDatePicker({
|
||||
id,
|
||||
fromStr: start,
|
||||
toStr: end,
|
||||
from: fromDate,
|
||||
to: toDate,
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toDate = formatDate(end);
|
||||
dispatch(
|
||||
|
@ -284,6 +290,7 @@ export const makeMapStateToProps = () => {
|
|||
const getToStrSelector = toStrSelector();
|
||||
return (state: State, { id }: OwnProps) => {
|
||||
const inputsRange: InputsRange = getOr({}, `inputs.${id}`, state);
|
||||
|
||||
return {
|
||||
duration: getDurationSelector(inputsRange),
|
||||
end: getEndSelector(inputsRange),
|
||||
|
@ -292,7 +299,7 @@ export const makeMapStateToProps = () => {
|
|||
kind: getKindSelector(inputsRange),
|
||||
kqlQuery: getKqlQuerySelector(inputsRange) as inputsModel.GlobalKqlQuery,
|
||||
policy: getPolicySelector(inputsRange),
|
||||
queries: getQueriesSelector(inputsRange) as inputsModel.GlobalGraphqlQuery[],
|
||||
queries: getQueriesSelector(state, id),
|
||||
start: getStartSelector(inputsRange),
|
||||
toStr: getToStrSelector(inputsRange),
|
||||
};
|
||||
|
|
|
@ -17,6 +17,8 @@ import {
|
|||
} from './selectors';
|
||||
import { InputsRange, AbsoluteTimeRange, RelativeTimeRange } from '../../store/inputs/model';
|
||||
import { cloneDeep } from 'lodash/fp';
|
||||
import { mockGlobalState } from '../../mock';
|
||||
import { State } from '../../store';
|
||||
|
||||
describe('selectors', () => {
|
||||
let absoluteTime: AbsoluteTimeRange = {
|
||||
|
@ -42,6 +44,8 @@ describe('selectors', () => {
|
|||
filters: [],
|
||||
};
|
||||
|
||||
let mockState: State = mockGlobalState;
|
||||
|
||||
const getPolicySelector = policySelector();
|
||||
const getDurationSelector = durationSelector();
|
||||
const getKindSelector = kindSelector();
|
||||
|
@ -75,6 +79,8 @@ describe('selectors', () => {
|
|||
},
|
||||
filters: [],
|
||||
};
|
||||
|
||||
mockState = mockGlobalState;
|
||||
});
|
||||
|
||||
describe('#policySelector', () => {
|
||||
|
@ -375,34 +381,61 @@ describe('selectors', () => {
|
|||
|
||||
describe('#queriesSelector', () => {
|
||||
test('returns the same reference given the same identical input twice', () => {
|
||||
const result1 = getQueriesSelector(inputState);
|
||||
const result2 = getQueriesSelector(inputState);
|
||||
const myMock = {
|
||||
...mockState,
|
||||
inputs: {
|
||||
...mockState.inputs,
|
||||
global: inputState,
|
||||
},
|
||||
};
|
||||
const result1 = getQueriesSelector(myMock, 'global');
|
||||
const result2 = getQueriesSelector(myMock, 'global');
|
||||
expect(result1).toBe(result2);
|
||||
});
|
||||
|
||||
test('DOES NOT return the same reference given different input twice but with different deep copies since the query is not a primitive', () => {
|
||||
const clone = cloneDeep(inputState);
|
||||
const result1 = getQueriesSelector(inputState);
|
||||
const result2 = getQueriesSelector(clone);
|
||||
const myMock = {
|
||||
...mockState,
|
||||
inputs: {
|
||||
...mockState.inputs,
|
||||
global: inputState,
|
||||
},
|
||||
};
|
||||
const clone = cloneDeep(myMock);
|
||||
const result1 = getQueriesSelector(myMock, 'global');
|
||||
const result2 = getQueriesSelector(clone, 'global');
|
||||
expect(result1).not.toBe(result2);
|
||||
});
|
||||
|
||||
test('returns a different reference even if the contents are the same since query is an array and not a primitive', () => {
|
||||
const result1 = getQueriesSelector(inputState);
|
||||
const change: InputsRange = {
|
||||
...inputState,
|
||||
queries: [
|
||||
{
|
||||
loading: false,
|
||||
id: '1',
|
||||
inspect: { dsl: [], response: [] },
|
||||
isInspected: false,
|
||||
refetch: null,
|
||||
selectedInspectIndex: 0,
|
||||
},
|
||||
],
|
||||
const myMock = {
|
||||
...mockState,
|
||||
inputs: {
|
||||
...mockState.inputs,
|
||||
global: inputState,
|
||||
},
|
||||
};
|
||||
const result2 = getQueriesSelector(change);
|
||||
const result1 = getQueriesSelector(myMock, 'global');
|
||||
const myMockChange: State = {
|
||||
...myMock,
|
||||
inputs: {
|
||||
...mockState.inputs,
|
||||
global: {
|
||||
...mockState.inputs.global,
|
||||
queries: [
|
||||
{
|
||||
loading: false,
|
||||
id: '1',
|
||||
inspect: { dsl: [], response: [] },
|
||||
isInspected: false,
|
||||
refetch: null,
|
||||
selectedInspectIndex: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result2 = getQueriesSelector(myMockChange, 'global');
|
||||
expect(result1).not.toBe(result2);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,7 +4,10 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { isEmpty } from 'lodash';
|
||||
import { createSelector } from 'reselect';
|
||||
import { State } from '../../store';
|
||||
import { InputsModelId } from '../../store/inputs/constants';
|
||||
import { Policy, InputsRange, TimeRange, GlobalQuery } from '../../store/inputs/model';
|
||||
|
||||
export const getPolicy = (inputState: InputsRange): Policy => inputState.policy;
|
||||
|
@ -13,6 +16,16 @@ export const getTimerange = (inputState: InputsRange): TimeRange => inputState.t
|
|||
|
||||
export const getQueries = (inputState: InputsRange): GlobalQuery[] => inputState.queries;
|
||||
|
||||
export const getGlobalQueries = (state: State, id: InputsModelId): GlobalQuery[] => {
|
||||
const inputsRange = state.inputs[id];
|
||||
return !isEmpty(inputsRange.linkTo)
|
||||
? inputsRange.linkTo.reduce<GlobalQuery[]>((acc, linkToId) => {
|
||||
const linkToIdInputsRange: InputsRange = state.inputs[linkToId];
|
||||
return [...acc, ...linkToIdInputsRange.queries];
|
||||
}, inputsRange.queries)
|
||||
: inputsRange.queries;
|
||||
};
|
||||
|
||||
export const policySelector = () => createSelector(getPolicy, (policy) => policy.kind);
|
||||
|
||||
export const durationSelector = () => createSelector(getPolicy, (policy) => policy.duration);
|
||||
|
@ -31,7 +44,7 @@ export const isLoadingSelector = () =>
|
|||
createSelector(getQueries, (queries) => queries.some((i) => i.loading === true));
|
||||
|
||||
export const queriesSelector = () =>
|
||||
createSelector(getQueries, (queries) => queries.filter((q) => q.id !== 'kql'));
|
||||
createSelector(getGlobalQueries, (queries) => queries.filter((q) => q.id !== 'kql'));
|
||||
|
||||
export const kqlQuerySelector = () =>
|
||||
createSelector(getQueries, (queries) => queries.find((q) => q.id === 'kql'));
|
||||
|
|
|
@ -16,6 +16,8 @@ export const setAbsoluteRangeDatePicker = actionCreator<{
|
|||
id: InputsModelId;
|
||||
from: string;
|
||||
to: string;
|
||||
fromStr?: string;
|
||||
toStr?: string;
|
||||
}>('SET_ABSOLUTE_RANGE_DATE_PICKER');
|
||||
|
||||
export const setTimelineRangeDatePicker = actionCreator<{
|
||||
|
|
|
@ -11,8 +11,8 @@ import { Query, Filter, SavedQuery } from '../../../../../../../src/plugins/data
|
|||
|
||||
export interface AbsoluteTimeRange {
|
||||
kind: 'absolute';
|
||||
fromStr: undefined;
|
||||
toStr: undefined;
|
||||
fromStr?: string;
|
||||
toStr?: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
|
|
@ -149,16 +149,19 @@ export const inputsReducer = reducerWithInitialState(initialInputsState)
|
|||
},
|
||||
};
|
||||
})
|
||||
.case(setAbsoluteRangeDatePicker, (state, { id, from, to }) => {
|
||||
const timerange: TimeRange = {
|
||||
kind: 'absolute',
|
||||
fromStr: undefined,
|
||||
toStr: undefined,
|
||||
from,
|
||||
to,
|
||||
};
|
||||
return updateInputTimerange(id, timerange, state);
|
||||
})
|
||||
.case(
|
||||
setAbsoluteRangeDatePicker,
|
||||
(state, { id, from, to, fromStr = undefined, toStr = undefined }) => {
|
||||
const timerange: TimeRange = {
|
||||
kind: 'absolute',
|
||||
fromStr,
|
||||
toStr,
|
||||
from,
|
||||
to,
|
||||
};
|
||||
return updateInputTimerange(id, timerange, state);
|
||||
}
|
||||
)
|
||||
.case(setRelativeRangeDatePicker, (state, { id, fromStr, from, to, toStr }) => {
|
||||
const timerange: TimeRange = {
|
||||
kind: 'relative',
|
||||
|
|
|
@ -86,18 +86,25 @@ export const defaultIndexNamesSelector = () => {
|
|||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const EXLCUDE_ELASTIC_CLOUD_INDEX = '-*elastic-cloud-logs-*';
|
||||
const EXCLUDE_ELASTIC_CLOUD_INDEX = '-*elastic-cloud-logs-*';
|
||||
export const getSourcererScopeSelector = () => {
|
||||
const getScopesSelector = scopesSelector();
|
||||
|
||||
const mapStateToProps = (state: State, scopeId: SourcererScopeName): ManageScope => ({
|
||||
...getScopesSelector(state)[scopeId],
|
||||
selectedPatterns: getScopesSelector(state)[scopeId].selectedPatterns.some(
|
||||
const mapStateToProps = (state: State, scopeId: SourcererScopeName): ManageScope => {
|
||||
const selectedPatterns = getScopesSelector(state)[scopeId].selectedPatterns.some(
|
||||
(index) => index === 'logs-*'
|
||||
)
|
||||
? [...getScopesSelector(state)[scopeId].selectedPatterns, EXLCUDE_ELASTIC_CLOUD_INDEX]
|
||||
: getScopesSelector(state)[scopeId].selectedPatterns,
|
||||
});
|
||||
? [...getScopesSelector(state)[scopeId].selectedPatterns, EXCLUDE_ELASTIC_CLOUD_INDEX]
|
||||
: getScopesSelector(state)[scopeId].selectedPatterns;
|
||||
return {
|
||||
...getScopesSelector(state)[scopeId],
|
||||
selectedPatterns,
|
||||
indexPattern: {
|
||||
...getScopesSelector(state)[scopeId].indexPattern,
|
||||
title: selectedPatterns.join(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
|
|
@ -20,6 +20,7 @@ import {
|
|||
reputationRenderer,
|
||||
DefaultFieldRenderer,
|
||||
DEFAULT_MORE_MAX_HEIGHT,
|
||||
DefaultFieldRendererOverflow,
|
||||
MoreContainer,
|
||||
} from './field_renderers';
|
||||
import { mockData } from '../../../network/components/details/mock';
|
||||
|
@ -330,4 +331,45 @@ describe('Field Renderers', () => {
|
|||
expect(render).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DefaultFieldRendererOverflow', () => {
|
||||
const idPrefix = 'prefix-1';
|
||||
const rowItems = ['item1', 'item2', 'item3', 'item4', 'item5', 'item6', 'item7'];
|
||||
|
||||
test('it should render the length of items after the overflowIndexStart', () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<DefaultFieldRendererOverflow
|
||||
idPrefix={idPrefix}
|
||||
rowItems={rowItems}
|
||||
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
|
||||
overflowIndexStart={5}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(wrapper.text()).toEqual(' ,+2 More');
|
||||
expect(wrapper.find('[data-test-subj="more-container"]').first().exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('it should render the items after overflowIndexStart in the popover', () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<DefaultFieldRendererOverflow
|
||||
idPrefix={idPrefix}
|
||||
rowItems={rowItems}
|
||||
moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT}
|
||||
overflowIndexStart={5}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
wrapper.find('button').first().simulate('click');
|
||||
wrapper.update();
|
||||
expect(wrapper.find('.euiPopover').first().exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test-subj="more-container"]').first().text()).toEqual(
|
||||
'item6item7'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -260,12 +260,12 @@ MoreContainer.displayName = 'MoreContainer';
|
|||
export const DefaultFieldRendererOverflow = React.memo<DefaultFieldRendererOverflowProps>(
|
||||
({ idPrefix, moreMaxHeight, overflowIndexStart = 5, render, rowItems }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const handleClose = useCallback(() => setIsOpen(false), []);
|
||||
const togglePopover = useCallback(() => setIsOpen((currentIsOpen) => !currentIsOpen), []);
|
||||
const button = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{' ,'}
|
||||
<EuiButtonEmpty size="xs" onClick={handleClose}>
|
||||
<EuiButtonEmpty size="xs" onClick={togglePopover}>
|
||||
{`+${rowItems.length - overflowIndexStart} `}
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.fieldRenderers.moreLabel"
|
||||
|
@ -274,7 +274,7 @@ export const DefaultFieldRendererOverflow = React.memo<DefaultFieldRendererOverf
|
|||
</EuiButtonEmpty>
|
||||
</>
|
||||
),
|
||||
[handleClose, overflowIndexStart, rowItems.length]
|
||||
[togglePopover, overflowIndexStart, rowItems.length]
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -284,7 +284,7 @@ export const DefaultFieldRendererOverflow = React.memo<DefaultFieldRendererOverf
|
|||
id="popover"
|
||||
button={button}
|
||||
isOpen={isOpen}
|
||||
closePopover={handleClose}
|
||||
closePopover={togglePopover}
|
||||
repositionOnScroll
|
||||
>
|
||||
<MoreContainer
|
||||
|
|
|
@ -905,6 +905,7 @@ In other use cases the message field can be used to concatenate different values
|
|||
start="2018-03-23T18:49:23.132Z"
|
||||
status="active"
|
||||
timelineType="default"
|
||||
timerangeKind="absolute"
|
||||
toggleColumn={[MockFunction]}
|
||||
usersViewing={
|
||||
Array [
|
||||
|
|
|
@ -94,7 +94,6 @@ const EventsComponent: React.FC<Props> = ({
|
|||
columnHeaders={columnHeaders}
|
||||
columnRenderers={columnRenderers}
|
||||
containerElementRef={containerElementRef}
|
||||
disableSensorVisibility={data != null && data.length < 101}
|
||||
docValueFields={docValueFields}
|
||||
event={event}
|
||||
eventIdToNoteIds={eventIdToNoteIds}
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
import uuid from 'uuid';
|
||||
import VisibilitySensor from 'react-visibility-sensor';
|
||||
|
||||
import { BrowserFields, DocValueFields } from '../../../../../common/containers/source';
|
||||
import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector';
|
||||
|
@ -19,7 +18,6 @@ import {
|
|||
import { Note } from '../../../../../common/lib/note';
|
||||
import { ColumnHeaderOptions, TimelineModel } from '../../../../../timelines/store/timeline/model';
|
||||
import { AddNoteToEvent, UpdateNote } from '../../../notes/helpers';
|
||||
import { SkeletonRow } from '../../skeleton_row';
|
||||
import {
|
||||
OnColumnResized,
|
||||
OnPinEvent,
|
||||
|
@ -38,6 +36,8 @@ import { NoteCards } from '../../../notes/note_cards';
|
|||
import { useEventDetailsWidthContext } from '../../../../../common/components/events_viewer/event_details_width_context';
|
||||
import { EventColumnView } from './event_column_view';
|
||||
import { inputsModel } from '../../../../../common/store';
|
||||
import { TimelineId } from '../../../../../../common/types/timeline';
|
||||
import { activeTimeline } from '../../../../containers/active_timeline_context';
|
||||
|
||||
interface Props {
|
||||
actionsColumnWidth: number;
|
||||
|
@ -46,7 +46,6 @@ interface Props {
|
|||
browserFields: BrowserFields;
|
||||
columnHeaders: ColumnHeaderOptions[];
|
||||
columnRenderers: ColumnRenderer[];
|
||||
disableSensorVisibility: boolean;
|
||||
docValueFields: DocValueFields[];
|
||||
event: TimelineItem;
|
||||
eventIdToNoteIds: Readonly<Record<string, string[]>>;
|
||||
|
@ -73,33 +72,6 @@ export const getNewNoteId = (): string => uuid.v4();
|
|||
|
||||
const emptyDetails: TimelineEventsDetailsItem[] = [];
|
||||
|
||||
/**
|
||||
* This is the default row height whenever it is a plain row renderer and not a custom row height.
|
||||
* We use this value when we do not know the height of a particular row.
|
||||
*/
|
||||
const DEFAULT_ROW_HEIGHT = '32px';
|
||||
|
||||
/**
|
||||
* This is the top offset in pixels of the top part of the timeline. The UI area where you do your
|
||||
* drag and drop and filtering. It is a positive number in pixels of _PART_ of the header but not
|
||||
* the entire header. We leave room for some rows to render behind the drag and drop so they might be
|
||||
* visible by the time the user scrolls upwards. All other DOM elements are replaced with their "blank"
|
||||
* rows.
|
||||
*/
|
||||
const TOP_OFFSET = 50;
|
||||
|
||||
/**
|
||||
* This is the bottom offset in pixels of the bottom part of the timeline. The UI area right below the
|
||||
* timeline which is the footer. Since the footer is so incredibly small we don't have enough room to
|
||||
* render around 5 rows below the timeline to get the user the best chance of always scrolling without seeing
|
||||
* "blank rows". The negative number is to give the bottom of the browser window a bit of invisible space to
|
||||
* keep around 5 rows rendering below it. All other DOM elements are replaced with their "blank"
|
||||
* rows.
|
||||
*/
|
||||
const BOTTOM_OFFSET = -500;
|
||||
|
||||
const VISIBILITY_SENSOR_OFFSET = { top: TOP_OFFSET, bottom: BOTTOM_OFFSET };
|
||||
|
||||
const emptyNotes: string[] = [];
|
||||
|
||||
const EventsTrSupplementContainerWrapper = React.memo(({ children }) => {
|
||||
|
@ -116,7 +88,6 @@ const StatefulEventComponent: React.FC<Props> = ({
|
|||
containerElementRef,
|
||||
columnHeaders,
|
||||
columnRenderers,
|
||||
disableSensorVisibility = true,
|
||||
docValueFields,
|
||||
event,
|
||||
eventIdToNoteIds,
|
||||
|
@ -138,7 +109,9 @@ const StatefulEventComponent: React.FC<Props> = ({
|
|||
toggleColumn,
|
||||
updateNote,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState<{ [eventId: string]: boolean }>({});
|
||||
const [expanded, setExpanded] = useState<{ [eventId: string]: boolean }>(
|
||||
timelineId === TimelineId.active ? activeTimeline.getExpandedEventIds() : {}
|
||||
);
|
||||
const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({});
|
||||
const { status: timelineStatus } = useShallowEqualSelector<TimelineModel>(
|
||||
(state) => state.timeline.timelineById[timelineId]
|
||||
|
@ -148,21 +121,21 @@ const StatefulEventComponent: React.FC<Props> = ({
|
|||
docValueFields,
|
||||
indexName: event._index!,
|
||||
eventId: event._id,
|
||||
skip: !expanded[event._id],
|
||||
skip: !expanded || !expanded[event._id],
|
||||
});
|
||||
|
||||
const onToggleShowNotes = useCallback(() => {
|
||||
const eventId = event._id;
|
||||
setShowNotes({ ...showNotes, [eventId]: !showNotes[eventId] });
|
||||
}, [event, showNotes]);
|
||||
setShowNotes((prevShowNotes) => ({ ...prevShowNotes, [eventId]: !prevShowNotes[eventId] }));
|
||||
}, [event]);
|
||||
|
||||
const onToggleExpanded = useCallback(() => {
|
||||
const eventId = event._id;
|
||||
setExpanded({
|
||||
...expanded,
|
||||
[eventId]: !expanded[eventId],
|
||||
});
|
||||
}, [event, expanded]);
|
||||
setExpanded((prevExpanded) => ({ ...prevExpanded, [eventId]: !prevExpanded[eventId] }));
|
||||
if (timelineId === TimelineId.active) {
|
||||
activeTimeline.toggleExpandedEvent(eventId);
|
||||
}
|
||||
}, [event._id, timelineId]);
|
||||
|
||||
const associateNote = useCallback(
|
||||
(noteId: string) => {
|
||||
|
@ -174,152 +147,87 @@ const StatefulEventComponent: React.FC<Props> = ({
|
|||
[addNoteToEvent, event, isEventPinned, onPinEvent]
|
||||
);
|
||||
|
||||
// Number of current columns plus one for actions.
|
||||
const columnCount = columnHeaders.length + 1;
|
||||
|
||||
const VisibilitySensorContent = useCallback(
|
||||
({ isVisible }) => {
|
||||
if (isVisible || disableSensorVisibility) {
|
||||
return (
|
||||
<EventsTrGroup
|
||||
className={STATEFUL_EVENT_CSS_CLASS_NAME}
|
||||
data-test-subj="event"
|
||||
eventType={getEventType(event.ecs)}
|
||||
isBuildingBlockType={isEventBuildingBlockType(event.ecs)}
|
||||
showLeftBorder={!isEventViewer}
|
||||
ref={divElement}
|
||||
>
|
||||
<EventColumnView
|
||||
id={event._id}
|
||||
actionsColumnWidth={actionsColumnWidth}
|
||||
associateNote={associateNote}
|
||||
columnHeaders={columnHeaders}
|
||||
columnRenderers={columnRenderers}
|
||||
data={event.data}
|
||||
ecsData={event.ecs}
|
||||
expanded={!!expanded[event._id]}
|
||||
eventIdToNoteIds={eventIdToNoteIds}
|
||||
getNotesByIds={getNotesByIds}
|
||||
isEventPinned={isEventPinned}
|
||||
isEventViewer={isEventViewer}
|
||||
loading={loading}
|
||||
loadingEventIds={loadingEventIds}
|
||||
onColumnResized={onColumnResized}
|
||||
onEventToggled={onToggleExpanded}
|
||||
onPinEvent={onPinEvent}
|
||||
onRowSelected={onRowSelected}
|
||||
onUnPinEvent={onUnPinEvent}
|
||||
refetch={refetch}
|
||||
onRuleChange={onRuleChange}
|
||||
selectedEventIds={selectedEventIds}
|
||||
showCheckboxes={showCheckboxes}
|
||||
showNotes={!!showNotes[event._id]}
|
||||
timelineId={timelineId}
|
||||
toggleShowNotes={onToggleShowNotes}
|
||||
updateNote={updateNote}
|
||||
/>
|
||||
|
||||
<EventsTrSupplementContainerWrapper>
|
||||
<EventsTrSupplement
|
||||
className="siemEventsTable__trSupplement--notes"
|
||||
data-test-subj="event-notes-flex-item"
|
||||
>
|
||||
<NoteCards
|
||||
associateNote={associateNote}
|
||||
data-test-subj="note-cards"
|
||||
getNewNoteId={getNewNoteId}
|
||||
getNotesByIds={getNotesByIds}
|
||||
noteIds={eventIdToNoteIds[event._id] || emptyNotes}
|
||||
showAddNote={!!showNotes[event._id]}
|
||||
status={timelineStatus}
|
||||
toggleShowAddNote={onToggleShowNotes}
|
||||
updateNote={updateNote}
|
||||
/>
|
||||
</EventsTrSupplement>
|
||||
|
||||
{getRowRenderer(event.ecs, rowRenderers).renderRow({
|
||||
browserFields,
|
||||
data: event.ecs,
|
||||
timelineId,
|
||||
})}
|
||||
|
||||
<EventsTrSupplement
|
||||
className="siemEventsTable__trSupplement--attributes"
|
||||
data-test-subj="event-details"
|
||||
>
|
||||
<ExpandableEvent
|
||||
browserFields={browserFields}
|
||||
columnHeaders={columnHeaders}
|
||||
event={detailsData || emptyDetails}
|
||||
forceExpand={!!expanded[event._id] && !loading}
|
||||
id={event._id}
|
||||
onEventToggled={onToggleExpanded}
|
||||
onUpdateColumns={onUpdateColumns}
|
||||
timelineId={timelineId}
|
||||
toggleColumn={toggleColumn}
|
||||
/>
|
||||
</EventsTrSupplement>
|
||||
</EventsTrSupplementContainerWrapper>
|
||||
</EventsTrGroup>
|
||||
);
|
||||
} else {
|
||||
// Height place holder for visibility detection as well as re-rendering sections.
|
||||
const height =
|
||||
divElement.current != null && divElement.current!.clientHeight
|
||||
? `${divElement.current!.clientHeight}px`
|
||||
: DEFAULT_ROW_HEIGHT;
|
||||
|
||||
return <SkeletonRow cellCount={columnCount} rowHeight={height} />;
|
||||
}
|
||||
},
|
||||
[
|
||||
actionsColumnWidth,
|
||||
associateNote,
|
||||
browserFields,
|
||||
columnCount,
|
||||
columnHeaders,
|
||||
columnRenderers,
|
||||
detailsData,
|
||||
disableSensorVisibility,
|
||||
event._id,
|
||||
event.data,
|
||||
event.ecs,
|
||||
eventIdToNoteIds,
|
||||
expanded,
|
||||
getNotesByIds,
|
||||
isEventPinned,
|
||||
isEventViewer,
|
||||
loading,
|
||||
loadingEventIds,
|
||||
onColumnResized,
|
||||
onPinEvent,
|
||||
onRowSelected,
|
||||
onToggleExpanded,
|
||||
onToggleShowNotes,
|
||||
onUnPinEvent,
|
||||
onUpdateColumns,
|
||||
refetch,
|
||||
onRuleChange,
|
||||
rowRenderers,
|
||||
selectedEventIds,
|
||||
showCheckboxes,
|
||||
showNotes,
|
||||
timelineId,
|
||||
timelineStatus,
|
||||
toggleColumn,
|
||||
updateNote,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<VisibilitySensor
|
||||
partialVisibility={true}
|
||||
scrollCheck={true}
|
||||
containment={containerElementRef}
|
||||
offset={VISIBILITY_SENSOR_OFFSET}
|
||||
<EventsTrGroup
|
||||
className={STATEFUL_EVENT_CSS_CLASS_NAME}
|
||||
data-test-subj="event"
|
||||
eventType={getEventType(event.ecs)}
|
||||
isBuildingBlockType={isEventBuildingBlockType(event.ecs)}
|
||||
showLeftBorder={!isEventViewer}
|
||||
ref={divElement}
|
||||
>
|
||||
{VisibilitySensorContent}
|
||||
</VisibilitySensor>
|
||||
<EventColumnView
|
||||
id={event._id}
|
||||
actionsColumnWidth={actionsColumnWidth}
|
||||
associateNote={associateNote}
|
||||
columnHeaders={columnHeaders}
|
||||
columnRenderers={columnRenderers}
|
||||
data={event.data}
|
||||
ecsData={event.ecs}
|
||||
expanded={!!expanded[event._id]}
|
||||
eventIdToNoteIds={eventIdToNoteIds}
|
||||
getNotesByIds={getNotesByIds}
|
||||
isEventPinned={isEventPinned}
|
||||
isEventViewer={isEventViewer}
|
||||
loading={loading}
|
||||
loadingEventIds={loadingEventIds}
|
||||
onColumnResized={onColumnResized}
|
||||
onEventToggled={onToggleExpanded}
|
||||
onPinEvent={onPinEvent}
|
||||
onRowSelected={onRowSelected}
|
||||
onUnPinEvent={onUnPinEvent}
|
||||
refetch={refetch}
|
||||
onRuleChange={onRuleChange}
|
||||
selectedEventIds={selectedEventIds}
|
||||
showCheckboxes={showCheckboxes}
|
||||
showNotes={!!showNotes[event._id]}
|
||||
timelineId={timelineId}
|
||||
toggleShowNotes={onToggleShowNotes}
|
||||
updateNote={updateNote}
|
||||
/>
|
||||
|
||||
<EventsTrSupplementContainerWrapper>
|
||||
<EventsTrSupplement
|
||||
className="siemEventsTable__trSupplement--notes"
|
||||
data-test-subj="event-notes-flex-item"
|
||||
>
|
||||
<NoteCards
|
||||
associateNote={associateNote}
|
||||
data-test-subj="note-cards"
|
||||
getNewNoteId={getNewNoteId}
|
||||
getNotesByIds={getNotesByIds}
|
||||
noteIds={eventIdToNoteIds[event._id] || emptyNotes}
|
||||
showAddNote={!!showNotes[event._id]}
|
||||
status={timelineStatus}
|
||||
toggleShowAddNote={onToggleShowNotes}
|
||||
updateNote={updateNote}
|
||||
/>
|
||||
</EventsTrSupplement>
|
||||
|
||||
{getRowRenderer(event.ecs, rowRenderers).renderRow({
|
||||
browserFields,
|
||||
data: event.ecs,
|
||||
timelineId,
|
||||
})}
|
||||
|
||||
<EventsTrSupplement
|
||||
className="siemEventsTable__trSupplement--attributes"
|
||||
data-test-subj="event-details"
|
||||
>
|
||||
<ExpandableEvent
|
||||
browserFields={browserFields}
|
||||
columnHeaders={columnHeaders}
|
||||
event={detailsData || emptyDetails}
|
||||
forceExpand={!!expanded[event._id] && !loading}
|
||||
id={event._id}
|
||||
onEventToggled={onToggleExpanded}
|
||||
onUpdateColumns={onUpdateColumns}
|
||||
timelineId={timelineId}
|
||||
toggleColumn={toggleColumn}
|
||||
/>
|
||||
</EventsTrSupplement>
|
||||
</EventsTrSupplementContainerWrapper>
|
||||
</EventsTrGroup>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -27,6 +27,11 @@ export interface OwnProps {
|
|||
|
||||
export type Props = OwnProps & PropsFromRedux;
|
||||
|
||||
const isTimerangeSame = (prevProps: Props, nextProps: Props) =>
|
||||
prevProps.end === nextProps.end &&
|
||||
prevProps.start === nextProps.start &&
|
||||
prevProps.timerangeKind === nextProps.timerangeKind;
|
||||
|
||||
const StatefulTimelineComponent = React.memo<Props>(
|
||||
({
|
||||
columns,
|
||||
|
@ -51,6 +56,7 @@ const StatefulTimelineComponent = React.memo<Props>(
|
|||
start,
|
||||
status,
|
||||
timelineType,
|
||||
timerangeKind,
|
||||
updateItemsPerPage,
|
||||
upsertColumn,
|
||||
usersViewing,
|
||||
|
@ -125,13 +131,14 @@ const StatefulTimelineComponent = React.memo<Props>(
|
|||
status={status}
|
||||
toggleColumn={toggleColumn}
|
||||
timelineType={timelineType}
|
||||
timerangeKind={timerangeKind}
|
||||
usersViewing={usersViewing}
|
||||
/>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.end === nextProps.end &&
|
||||
isTimerangeSame(prevProps, nextProps) &&
|
||||
prevProps.graphEventId === nextProps.graphEventId &&
|
||||
prevProps.id === nextProps.id &&
|
||||
prevProps.isLive === nextProps.isLive &&
|
||||
|
@ -142,7 +149,6 @@ const StatefulTimelineComponent = React.memo<Props>(
|
|||
prevProps.kqlQueryExpression === nextProps.kqlQueryExpression &&
|
||||
prevProps.show === nextProps.show &&
|
||||
prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg &&
|
||||
prevProps.start === nextProps.start &&
|
||||
prevProps.timelineType === nextProps.timelineType &&
|
||||
prevProps.status === nextProps.status &&
|
||||
deepEqual(prevProps.columns, nextProps.columns) &&
|
||||
|
@ -209,6 +215,7 @@ const makeMapStateToProps = () => {
|
|||
start: input.timerange.from,
|
||||
status,
|
||||
timelineType,
|
||||
timerangeKind: input.timerange.kind,
|
||||
};
|
||||
};
|
||||
return mapStateToProps;
|
||||
|
|
|
@ -116,6 +116,7 @@ describe('Timeline', () => {
|
|||
start: startDate,
|
||||
status: TimelineStatus.active,
|
||||
timelineType: TimelineType.default,
|
||||
timerangeKind: 'absolute',
|
||||
toggleColumn: jest.fn(),
|
||||
usersViewing: ['elastic'],
|
||||
};
|
||||
|
|
|
@ -112,6 +112,7 @@ export interface Props {
|
|||
start: string;
|
||||
status: TimelineStatusLiteral;
|
||||
timelineType: TimelineType;
|
||||
timerangeKind: 'absolute' | 'relative';
|
||||
toggleColumn: (column: ColumnHeaderOptions) => void;
|
||||
usersViewing: string[];
|
||||
}
|
||||
|
@ -143,6 +144,7 @@ export const TimelineComponent: React.FC<Props> = ({
|
|||
status,
|
||||
sort,
|
||||
timelineType,
|
||||
timerangeKind,
|
||||
toggleColumn,
|
||||
usersViewing,
|
||||
}) => {
|
||||
|
@ -212,6 +214,7 @@ export const TimelineComponent: React.FC<Props> = ({
|
|||
startDate: start,
|
||||
skip: !canQueryTimeline,
|
||||
sort: timelineQuerySortField,
|
||||
timerangeKind,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* 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 { TimelineArgs } from '.';
|
||||
import { TimelineEventsAllRequestOptions } from '../../../common/search_strategy/timeline';
|
||||
|
||||
/*
|
||||
* Future Engineer
|
||||
* This class is just there to manage temporarily the reload of the active timeline when switching tabs
|
||||
* because of the bootstrap of the security solution app, we will always trigger the query
|
||||
* to avoid it we will cache its request and response so we can go back where the user was before switching tabs
|
||||
*
|
||||
* !!! Important !!! this is just there until, we will have a better way to bootstrap the app
|
||||
* I did not want to put in the store because I was feeling it will feel less temporarily and I did not want other engineer using it
|
||||
*
|
||||
*/
|
||||
class ActiveTimelineEvents {
|
||||
private _activePage: number = 0;
|
||||
private _expandedEventIds: Record<string, boolean> = {};
|
||||
private _pageName: string = '';
|
||||
private _request: TimelineEventsAllRequestOptions | null = null;
|
||||
private _response: TimelineArgs | null = null;
|
||||
|
||||
getActivePage() {
|
||||
return this._activePage;
|
||||
}
|
||||
|
||||
setActivePage(activePage: number) {
|
||||
this._activePage = activePage;
|
||||
}
|
||||
|
||||
getExpandedEventIds() {
|
||||
return this._expandedEventIds;
|
||||
}
|
||||
|
||||
toggleExpandedEvent(eventId: string) {
|
||||
this._expandedEventIds = {
|
||||
...this._expandedEventIds,
|
||||
[eventId]: !this._expandedEventIds[eventId],
|
||||
};
|
||||
}
|
||||
|
||||
setExpandedEventIds(expandedEventIds: Record<string, boolean>) {
|
||||
this._expandedEventIds = expandedEventIds;
|
||||
}
|
||||
|
||||
getPageName() {
|
||||
return this._pageName;
|
||||
}
|
||||
|
||||
setPageName(pageName: string) {
|
||||
this._pageName = pageName;
|
||||
}
|
||||
|
||||
getRequest() {
|
||||
return this._request;
|
||||
}
|
||||
|
||||
setRequest(req: TimelineEventsAllRequestOptions) {
|
||||
this._request = req;
|
||||
}
|
||||
|
||||
getResponse() {
|
||||
return this._response;
|
||||
}
|
||||
|
||||
setResponse(resp: TimelineArgs | null) {
|
||||
this._response = resp;
|
||||
}
|
||||
}
|
||||
|
||||
export const activeTimeline = new ActiveTimelineEvents();
|
|
@ -0,0 +1,210 @@
|
|||
/*
|
||||
* 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 { renderHook, act } from '@testing-library/react-hooks';
|
||||
import { initSortDefault, TimelineArgs, useTimelineEvents, UseTimelineEventsProps } from '.';
|
||||
import { SecurityPageName } from '../../../common/constants';
|
||||
import { TimelineId } from '../../../common/types/timeline';
|
||||
import { mockTimelineData } from '../../common/mock';
|
||||
import { useRouteSpy } from '../../common/utils/route/use_route_spy';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
jest.mock('react-redux', () => {
|
||||
const original = jest.requireActual('react-redux');
|
||||
|
||||
return {
|
||||
...original,
|
||||
useDispatch: () => mockDispatch,
|
||||
};
|
||||
});
|
||||
|
||||
const mockEvents = mockTimelineData.filter((i, index) => index <= 11);
|
||||
|
||||
const mockSearch = jest.fn();
|
||||
|
||||
jest.mock('../../common/lib/kibana', () => ({
|
||||
useKibana: jest.fn().mockReturnValue({
|
||||
services: {
|
||||
application: {
|
||||
capabilities: {
|
||||
siem: {
|
||||
crud: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
search: {
|
||||
search: jest.fn().mockImplementation((args) => {
|
||||
mockSearch();
|
||||
return {
|
||||
subscribe: jest.fn().mockImplementation(({ next }) => {
|
||||
next({
|
||||
isRunning: false,
|
||||
isPartial: false,
|
||||
inspect: {
|
||||
dsl: [],
|
||||
response: [],
|
||||
},
|
||||
edges: mockEvents.map((item) => ({ node: item })),
|
||||
pageInfo: {
|
||||
activePage: 0,
|
||||
totalPages: 10,
|
||||
},
|
||||
rawResponse: {},
|
||||
totalCount: mockTimelineData.length,
|
||||
});
|
||||
return { unsubscribe: jest.fn() };
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
notifications: {
|
||||
toasts: {
|
||||
addWarning: jest.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUseRouteSpy: jest.Mock = useRouteSpy as jest.Mock;
|
||||
jest.mock('../../common/utils/route/use_route_spy', () => ({
|
||||
useRouteSpy: jest.fn(),
|
||||
}));
|
||||
|
||||
mockUseRouteSpy.mockReturnValue([
|
||||
{
|
||||
pageName: SecurityPageName.overview,
|
||||
detailName: undefined,
|
||||
tabName: undefined,
|
||||
search: '',
|
||||
pathName: '/overview',
|
||||
},
|
||||
]);
|
||||
|
||||
describe('useTimelineEvents', () => {
|
||||
beforeEach(() => {
|
||||
mockSearch.mockReset();
|
||||
});
|
||||
|
||||
const startDate: string = '2020-07-07T08:20:18.966Z';
|
||||
const endDate: string = '3000-01-01T00:00:00.000Z';
|
||||
const props: UseTimelineEventsProps = {
|
||||
docValueFields: [],
|
||||
endDate: '',
|
||||
id: TimelineId.active,
|
||||
indexNames: ['filebeat-*'],
|
||||
fields: [],
|
||||
filterQuery: '',
|
||||
startDate: '',
|
||||
limit: 25,
|
||||
sort: initSortDefault,
|
||||
skip: false,
|
||||
};
|
||||
|
||||
test('init', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<
|
||||
UseTimelineEventsProps,
|
||||
[boolean, TimelineArgs]
|
||||
>((args) => useTimelineEvents(args), {
|
||||
initialProps: { ...props },
|
||||
});
|
||||
|
||||
// useEffect on params request
|
||||
await waitForNextUpdate();
|
||||
expect(result.current).toEqual([
|
||||
false,
|
||||
{
|
||||
events: [],
|
||||
id: TimelineId.active,
|
||||
inspect: result.current[1].inspect,
|
||||
loadPage: result.current[1].loadPage,
|
||||
pageInfo: result.current[1].pageInfo,
|
||||
refetch: result.current[1].refetch,
|
||||
totalCount: -1,
|
||||
updatedAt: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('happy path query', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate, rerender } = renderHook<
|
||||
UseTimelineEventsProps,
|
||||
[boolean, TimelineArgs]
|
||||
>((args) => useTimelineEvents(args), {
|
||||
initialProps: { ...props },
|
||||
});
|
||||
|
||||
// useEffect on params request
|
||||
await waitForNextUpdate();
|
||||
rerender({ ...props, startDate, endDate });
|
||||
// useEffect on params request
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(mockSearch).toHaveBeenCalledTimes(1);
|
||||
expect(result.current).toEqual([
|
||||
false,
|
||||
{
|
||||
events: mockEvents,
|
||||
id: TimelineId.active,
|
||||
inspect: result.current[1].inspect,
|
||||
loadPage: result.current[1].loadPage,
|
||||
pageInfo: result.current[1].pageInfo,
|
||||
refetch: result.current[1].refetch,
|
||||
totalCount: 31,
|
||||
updatedAt: result.current[1].updatedAt,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('Mock cache for active timeline when switching page', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate, rerender } = renderHook<
|
||||
UseTimelineEventsProps,
|
||||
[boolean, TimelineArgs]
|
||||
>((args) => useTimelineEvents(args), {
|
||||
initialProps: { ...props },
|
||||
});
|
||||
|
||||
// useEffect on params request
|
||||
await waitForNextUpdate();
|
||||
rerender({ ...props, startDate, endDate });
|
||||
// useEffect on params request
|
||||
await waitForNextUpdate();
|
||||
|
||||
mockUseRouteSpy.mockReturnValue([
|
||||
{
|
||||
pageName: SecurityPageName.timelines,
|
||||
detailName: undefined,
|
||||
tabName: undefined,
|
||||
search: '',
|
||||
pathName: '/timelines',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mockSearch).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(result.current).toEqual([
|
||||
false,
|
||||
{
|
||||
events: mockEvents,
|
||||
id: TimelineId.active,
|
||||
inspect: result.current[1].inspect,
|
||||
loadPage: result.current[1].loadPage,
|
||||
pageInfo: result.current[1].pageInfo,
|
||||
refetch: result.current[1].refetch,
|
||||
totalCount: 31,
|
||||
updatedAt: result.current[1].updatedAt,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -30,6 +30,9 @@ import {
|
|||
} from '../../../common/search_strategy';
|
||||
import { InspectResponse } from '../../types';
|
||||
import * as i18n from './translations';
|
||||
import { TimelineId } from '../../../common/types/timeline';
|
||||
import { useRouteSpy } from '../../common/utils/route/use_route_spy';
|
||||
import { activeTimeline } from './active_timeline_context';
|
||||
|
||||
export interface TimelineArgs {
|
||||
events: TimelineItem[];
|
||||
|
@ -44,7 +47,7 @@ export interface TimelineArgs {
|
|||
|
||||
type LoadPage = (newActivePage: number) => void;
|
||||
|
||||
interface UseTimelineEventsProps {
|
||||
export interface UseTimelineEventsProps {
|
||||
docValueFields?: DocValueFields[];
|
||||
filterQuery?: ESQuery | string;
|
||||
skip?: boolean;
|
||||
|
@ -55,17 +58,26 @@ interface UseTimelineEventsProps {
|
|||
limit: number;
|
||||
sort: SortField;
|
||||
startDate: string;
|
||||
timerangeKind?: 'absolute' | 'relative';
|
||||
}
|
||||
|
||||
const getTimelineEvents = (timelineEdges: TimelineEdges[]): TimelineItem[] =>
|
||||
timelineEdges.map((e: TimelineEdges) => e.node);
|
||||
|
||||
const ID = 'timelineEventsQuery';
|
||||
const initSortDefault = {
|
||||
export const initSortDefault = {
|
||||
field: '@timestamp',
|
||||
direction: Direction.asc,
|
||||
};
|
||||
|
||||
function usePreviousRequest(value: TimelineEventsAllRequestOptions | null) {
|
||||
const ref = useRef<TimelineEventsAllRequestOptions | null>(value);
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
});
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
export const useTimelineEvents = ({
|
||||
docValueFields,
|
||||
endDate,
|
||||
|
@ -77,13 +89,17 @@ export const useTimelineEvents = ({
|
|||
limit,
|
||||
sort = initSortDefault,
|
||||
skip = false,
|
||||
timerangeKind,
|
||||
}: UseTimelineEventsProps): [boolean, TimelineArgs] => {
|
||||
const [{ pageName }] = useRouteSpy();
|
||||
const dispatch = useDispatch();
|
||||
const { data, notifications } = useKibana().services;
|
||||
const refetch = useRef<inputsModel.Refetch>(noop);
|
||||
const abortCtrl = useRef(new AbortController());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activePage, setActivePage] = useState(0);
|
||||
const [activePage, setActivePage] = useState(
|
||||
id === TimelineId.active ? activeTimeline.getActivePage() : 0
|
||||
);
|
||||
const [timelineRequest, setTimelineRequest] = useState<TimelineEventsAllRequestOptions | null>(
|
||||
!skip
|
||||
? {
|
||||
|
@ -106,6 +122,7 @@ export const useTimelineEvents = ({
|
|||
}
|
||||
: null
|
||||
);
|
||||
const prevTimelineRequest = usePreviousRequest(timelineRequest);
|
||||
|
||||
const clearSignalsState = useCallback(() => {
|
||||
if (id != null && detectionsTimelineIds.some((timelineId) => timelineId === id)) {
|
||||
|
@ -117,18 +134,31 @@ export const useTimelineEvents = ({
|
|||
const wrappedLoadPage = useCallback(
|
||||
(newActivePage: number) => {
|
||||
clearSignalsState();
|
||||
|
||||
if (id === TimelineId.active) {
|
||||
activeTimeline.setExpandedEventIds({});
|
||||
activeTimeline.setActivePage(newActivePage);
|
||||
}
|
||||
|
||||
setActivePage(newActivePage);
|
||||
},
|
||||
[clearSignalsState]
|
||||
[clearSignalsState, id]
|
||||
);
|
||||
|
||||
const refetchGrid = useCallback(() => {
|
||||
if (refetch.current != null) {
|
||||
refetch.current();
|
||||
}
|
||||
wrappedLoadPage(0);
|
||||
}, [wrappedLoadPage]);
|
||||
|
||||
const [timelineResponse, setTimelineResponse] = useState<TimelineArgs>({
|
||||
id: ID,
|
||||
id,
|
||||
inspect: {
|
||||
dsl: [],
|
||||
response: [],
|
||||
},
|
||||
refetch: refetch.current,
|
||||
refetch: refetchGrid,
|
||||
totalCount: -1,
|
||||
pageInfo: {
|
||||
activePage: 0,
|
||||
|
@ -141,15 +171,13 @@ export const useTimelineEvents = ({
|
|||
|
||||
const timelineSearch = useCallback(
|
||||
(request: TimelineEventsAllRequestOptions | null) => {
|
||||
if (request == null) {
|
||||
if (request == null || pageName === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
let didCancel = false;
|
||||
const asyncSearch = async () => {
|
||||
abortCtrl.current = new AbortController();
|
||||
setLoading(true);
|
||||
|
||||
const searchSubscription$ = data.search
|
||||
.search<TimelineEventsAllRequestOptions, TimelineEventsAllStrategyResponse>(request, {
|
||||
strategy: 'securitySolutionTimelineSearchStrategy',
|
||||
|
@ -157,26 +185,39 @@ export const useTimelineEvents = ({
|
|||
})
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
if (isCompleteResponse(response)) {
|
||||
if (!didCancel) {
|
||||
setLoading(false);
|
||||
setTimelineResponse((prevResponse) => ({
|
||||
...prevResponse,
|
||||
events: getTimelineEvents(response.edges),
|
||||
inspect: getInspectResponse(response, prevResponse.inspect),
|
||||
pageInfo: response.pageInfo,
|
||||
refetch: refetch.current,
|
||||
totalCount: response.totalCount,
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
}
|
||||
searchSubscription$.unsubscribe();
|
||||
} else if (isErrorResponse(response)) {
|
||||
if (!didCancel) {
|
||||
setLoading(false);
|
||||
try {
|
||||
if (isCompleteResponse(response)) {
|
||||
if (!didCancel) {
|
||||
setLoading(false);
|
||||
|
||||
setTimelineResponse((prevResponse) => {
|
||||
const newTimelineResponse = {
|
||||
...prevResponse,
|
||||
events: getTimelineEvents(response.edges),
|
||||
inspect: getInspectResponse(response, prevResponse.inspect),
|
||||
pageInfo: response.pageInfo,
|
||||
totalCount: response.totalCount,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (id === TimelineId.active) {
|
||||
activeTimeline.setExpandedEventIds({});
|
||||
activeTimeline.setPageName(pageName);
|
||||
activeTimeline.setRequest(request);
|
||||
activeTimeline.setResponse(newTimelineResponse);
|
||||
}
|
||||
return newTimelineResponse;
|
||||
});
|
||||
}
|
||||
searchSubscription$.unsubscribe();
|
||||
} else if (isErrorResponse(response)) {
|
||||
if (!didCancel) {
|
||||
setLoading(false);
|
||||
}
|
||||
notifications.toasts.addWarning(i18n.ERROR_TIMELINE_EVENTS);
|
||||
searchSubscription$.unsubscribe();
|
||||
}
|
||||
} catch {
|
||||
notifications.toasts.addWarning(i18n.ERROR_TIMELINE_EVENTS);
|
||||
searchSubscription$.unsubscribe();
|
||||
}
|
||||
},
|
||||
error: (msg) => {
|
||||
|
@ -189,15 +230,43 @@ export const useTimelineEvents = ({
|
|||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (
|
||||
id === TimelineId.active &&
|
||||
activeTimeline.getPageName() !== '' &&
|
||||
pageName !== activeTimeline.getPageName()
|
||||
) {
|
||||
activeTimeline.setPageName(pageName);
|
||||
|
||||
abortCtrl.current.abort();
|
||||
setLoading(false);
|
||||
refetch.current = asyncSearch.bind(null, activeTimeline.getRequest());
|
||||
setTimelineResponse((prevResp) => {
|
||||
const resp = activeTimeline.getResponse();
|
||||
if (resp != null) {
|
||||
return {
|
||||
...resp,
|
||||
refetch: refetchGrid,
|
||||
loadPage: wrappedLoadPage,
|
||||
};
|
||||
}
|
||||
return prevResp;
|
||||
});
|
||||
if (activeTimeline.getResponse() != null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
abortCtrl.current.abort();
|
||||
asyncSearch();
|
||||
refetch.current = asyncSearch;
|
||||
|
||||
return () => {
|
||||
didCancel = true;
|
||||
abortCtrl.current.abort();
|
||||
};
|
||||
},
|
||||
[data.search, notifications.toasts]
|
||||
[data.search, id, notifications.toasts, pageName, refetchGrid, wrappedLoadPage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -251,8 +320,10 @@ export const useTimelineEvents = ({
|
|||
|
||||
if (activePage !== newActivePage) {
|
||||
setActivePage(newActivePage);
|
||||
if (id === TimelineId.active) {
|
||||
activeTimeline.setActivePage(newActivePage);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!skip &&
|
||||
!skipQueryForDetectionsPage(id, indexNames) &&
|
||||
|
@ -263,12 +334,13 @@ export const useTimelineEvents = ({
|
|||
return prevRequest;
|
||||
});
|
||||
}, [
|
||||
dispatch,
|
||||
indexNames,
|
||||
activePage,
|
||||
docValueFields,
|
||||
endDate,
|
||||
filterQuery,
|
||||
id,
|
||||
indexNames,
|
||||
limit,
|
||||
startDate,
|
||||
sort,
|
||||
|
@ -277,8 +349,13 @@ export const useTimelineEvents = ({
|
|||
]);
|
||||
|
||||
useEffect(() => {
|
||||
timelineSearch(timelineRequest);
|
||||
}, [timelineRequest, timelineSearch]);
|
||||
if (
|
||||
id !== TimelineId.active ||
|
||||
timerangeKind === 'absolute' ||
|
||||
!deepEqual(prevTimelineRequest, timelineRequest)
|
||||
)
|
||||
timelineSearch(timelineRequest);
|
||||
}, [id, prevTimelineRequest, timelineRequest, timelineSearch, timerangeKind]);
|
||||
|
||||
return [loading, timelineResponse];
|
||||
};
|
||||
|
|
|
@ -102,6 +102,7 @@ describe('epicLocalStorage', () => {
|
|||
status: TimelineStatus.active,
|
||||
sort,
|
||||
timelineType: TimelineType.default,
|
||||
timerangeKind: 'absolute',
|
||||
toggleColumn: jest.fn(),
|
||||
usersViewing: ['elastic'],
|
||||
};
|
||||
|
|
|
@ -26,12 +26,14 @@ import {
|
|||
TimelineTypeLiteral,
|
||||
TimelineType,
|
||||
RowRendererId,
|
||||
TimelineId,
|
||||
} from '../../../../common/types/timeline';
|
||||
import { normalizeTimeRange } from '../../../common/components/url_state/normalize_time_range';
|
||||
|
||||
import { timelineDefaults } from './defaults';
|
||||
import { ColumnHeaderOptions, KqlMode, TimelineModel } from './model';
|
||||
import { TimelineById } from './types';
|
||||
import { activeTimeline } from '../../containers/active_timeline_context';
|
||||
|
||||
export const isNotNull = <T>(value: T | null): value is T => value !== null;
|
||||
|
||||
|
@ -113,6 +115,17 @@ interface AddTimelineParams {
|
|||
timelineById: TimelineById;
|
||||
}
|
||||
|
||||
export const shouldResetActiveTimelineContext = (
|
||||
id: string,
|
||||
oldTimeline: TimelineModel,
|
||||
newTimeline: TimelineModel
|
||||
) => {
|
||||
if (id === TimelineId.active && oldTimeline.savedObjectId !== newTimeline.savedObjectId) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a saved object timeline to the store
|
||||
* and default the value to what need to be if values are null
|
||||
|
@ -121,13 +134,19 @@ export const addTimelineToStore = ({
|
|||
id,
|
||||
timeline,
|
||||
timelineById,
|
||||
}: AddTimelineParams): TimelineById => ({
|
||||
...timelineById,
|
||||
[id]: {
|
||||
...timeline,
|
||||
isLoading: timelineById[id].isLoading,
|
||||
},
|
||||
});
|
||||
}: AddTimelineParams): TimelineById => {
|
||||
if (shouldResetActiveTimelineContext(id, timelineById[id], timeline)) {
|
||||
activeTimeline.setActivePage(0);
|
||||
activeTimeline.setExpandedEventIds({});
|
||||
}
|
||||
return {
|
||||
...timelineById,
|
||||
[id]: {
|
||||
...timeline,
|
||||
isLoading: timelineById[id].isLoading,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
interface AddNewTimelineParams {
|
||||
columns: ColumnHeaderOptions[];
|
||||
|
|
Loading…
Reference in a new issue