[Security Solution][Timelines] - Resolve UI (#114350)

This commit is contained in:
Michael Olorunnisola 2021-10-19 18:51:06 -04:00 committed by GitHub
parent 0c9e4b2dec
commit 4f1e07117c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 868 additions and 103 deletions

View file

@ -16,6 +16,7 @@ import { useSourcererScope, getScopeFromPath } from '../../../../common/containe
import { TimelineId } from '../../../../../common/types/timeline';
import { AutoSaveWarningMsg } from '../../../../timelines/components/timeline/auto_save_warning';
import { Flyout } from '../../../../timelines/components/flyout';
import { useResolveRedirect } from '../../../../common/hooks/use_resolve_redirect';
export const BOTTOM_BAR_CLASSNAME = 'timeline-bottom-bar';
@ -26,6 +27,7 @@ export const SecuritySolutionBottomBar = React.memo(
const [showTimeline] = useShowTimeline();
const { indicesExist } = useSourcererScope(getScopeFromPath(pathname));
useResolveRedirect();
return indicesExist && showTimeline ? (
<>

View file

@ -25,6 +25,7 @@ export const UseUrlStateMemo = React.memo(
prevProps.pathName === nextProps.pathName &&
deepEqual(prevProps.urlState, nextProps.urlState) &&
deepEqual(prevProps.indexPattern, nextProps.indexPattern) &&
prevProps.search === nextProps.search &&
deepEqual(prevProps.navTabs, nextProps.navTabs)
);

View file

@ -124,7 +124,7 @@ export const useSetInitialStateFromUrl = () => {
[dispatch, updateTimeline, updateTimelineIsLoading]
);
return setInitialStateFromUrl;
return Object.freeze({ setInitialStateFromUrl, updateTimeline, updateTimelineIsLoading });
};
const updateTimerange = (newUrlStateString: string, dispatch: Dispatch) => {

View file

@ -0,0 +1,140 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { queryTimelineById } from '../../../timelines/components/open_timeline/helpers';
import { queryTimelineByIdOnUrlChange } from './query_timeline_by_id_on_url_change';
import * as urlHelpers from './helpers';
jest.mock('../../../timelines/components/open_timeline/helpers');
describe('queryTimelineByIdOnUrlChange', () => {
const oldTestTimelineId = '04e8ffb0-2c2a-11ec-949c-39005af91f70';
const newTestTimelineId = `${oldTestTimelineId}-newId`;
const oldTimelineRisonSearchString = `?timeline=(activeTab:query,graphEventId:%27%27,id:%27${oldTestTimelineId}%27,isOpen:!t)`;
const newTimelineRisonSearchString = `?timeline=(activeTab:query,graphEventId:%27%27,id:%27${newTestTimelineId}%27,isOpen:!t)`;
const mockUpdateTimeline = jest.fn();
const mockUpdateTimelineIsLoading = jest.fn();
const mockQueryTimelineById = jest.fn();
beforeEach(() => {
(queryTimelineById as jest.Mock).mockImplementation(mockQueryTimelineById);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('when search strings are empty', () => {
it('should not call queryTimelineById', () => {
queryTimelineByIdOnUrlChange({
oldSearch: '',
search: '',
timelineIdFromReduxStore: 'current-timeline-id',
updateTimeline: mockUpdateTimeline,
updateTimelineIsLoading: mockUpdateTimelineIsLoading,
});
expect(queryTimelineById).not.toBeCalled();
});
});
describe('when search string has not changed', () => {
it('should not call queryTimelineById', () => {
queryTimelineByIdOnUrlChange({
oldSearch: oldTimelineRisonSearchString,
search: oldTimelineRisonSearchString,
timelineIdFromReduxStore: 'timeline-id',
updateTimeline: mockUpdateTimeline,
updateTimelineIsLoading: mockUpdateTimelineIsLoading,
});
expect(queryTimelineById).not.toBeCalled();
});
});
describe('when decode rison fails', () => {
it('should not call queryTimelineById', () => {
jest.spyOn(urlHelpers, 'decodeRisonUrlState').mockImplementationOnce(() => {
throw new Error('Unable to decode');
});
queryTimelineByIdOnUrlChange({
oldSearch: oldTimelineRisonSearchString,
search: newTimelineRisonSearchString,
timelineIdFromReduxStore: '',
updateTimeline: mockUpdateTimeline,
updateTimelineIsLoading: mockUpdateTimelineIsLoading,
});
expect(queryTimelineById).not.toBeCalled();
});
});
describe('when new id is not provided', () => {
it('should not call queryTimelineById', () => {
queryTimelineByIdOnUrlChange({
oldSearch: oldTimelineRisonSearchString,
search: '?timeline=(activeTab:query)', // no id
timelineIdFromReduxStore: newTestTimelineId,
updateTimeline: mockUpdateTimeline,
updateTimelineIsLoading: mockUpdateTimelineIsLoading,
});
expect(queryTimelineById).not.toBeCalled();
});
});
describe('when new id matches the data in redux', () => {
it('should not call queryTimelineById', () => {
queryTimelineByIdOnUrlChange({
oldSearch: oldTimelineRisonSearchString,
search: newTimelineRisonSearchString,
timelineIdFromReduxStore: newTestTimelineId,
updateTimeline: mockUpdateTimeline,
updateTimelineIsLoading: mockUpdateTimelineIsLoading,
});
expect(queryTimelineById).not.toBeCalled();
});
});
// You can only redirect or run into conflict scenarios when already viewing a timeline
describe('when not actively on a page with timeline in the search field', () => {
it('should not call queryTimelineById', () => {
queryTimelineByIdOnUrlChange({
oldSearch: '?random=foo',
search: newTimelineRisonSearchString,
timelineIdFromReduxStore: oldTestTimelineId,
updateTimeline: mockUpdateTimeline,
updateTimelineIsLoading: mockUpdateTimelineIsLoading,
});
expect(queryTimelineById).not.toBeCalled();
});
});
describe('when an old timeline id exists, but a new id is given', () => {
it('should call queryTimelineById', () => {
queryTimelineByIdOnUrlChange({
oldSearch: oldTimelineRisonSearchString,
search: newTimelineRisonSearchString,
timelineIdFromReduxStore: oldTestTimelineId,
updateTimeline: mockUpdateTimeline,
updateTimelineIsLoading: mockUpdateTimelineIsLoading,
});
expect(queryTimelineById).toBeCalledWith({
activeTimelineTab: 'query',
duplicate: false,
graphEventId: '',
timelineId: newTestTimelineId,
openTimeline: true,
updateIsLoading: mockUpdateTimelineIsLoading,
updateTimeline: mockUpdateTimeline,
});
});
});
});

View file

@ -0,0 +1,84 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Action } from 'typescript-fsa';
import { DispatchUpdateTimeline } from '../../../timelines/components/open_timeline/types';
import { queryTimelineById } from '../../../timelines/components/open_timeline/helpers';
import { TimelineTabs } from '../../../../common/types/timeline';
import {
decodeRisonUrlState,
getQueryStringFromLocation,
getParamFromQueryString,
} from './helpers';
import { TimelineUrl } from '../../../timelines/store/timeline/model';
import { CONSTANTS } from './constants';
const getQueryStringKeyValue = ({ search, urlKey }: { search: string; urlKey: string }) =>
getParamFromQueryString(getQueryStringFromLocation(search), urlKey);
interface QueryTimelineIdOnUrlChange {
oldSearch?: string;
search: string;
timelineIdFromReduxStore: string;
updateTimeline: DispatchUpdateTimeline;
updateTimelineIsLoading: (status: { id: string; isLoading: boolean }) => Action<{
id: string;
isLoading: boolean;
}>;
}
/**
* After the initial load of the security solution, timeline is not updated when the timeline url search value is changed
* This is because those state changes happen in place and doesn't lead to a requerying of data for the new id.
* To circumvent this for the sake of the redirects needed for the saved object Id changes happening in 8.0
* We are actively pulling the id changes that take place for timeline in the url and calling the query below
* to request the new data.
*/
export const queryTimelineByIdOnUrlChange = ({
oldSearch,
search,
timelineIdFromReduxStore,
updateTimeline,
updateTimelineIsLoading,
}: QueryTimelineIdOnUrlChange) => {
const oldUrlStateString = getQueryStringKeyValue({
urlKey: CONSTANTS.timeline,
search: oldSearch ?? '',
});
const newUrlStateString = getQueryStringKeyValue({ urlKey: CONSTANTS.timeline, search });
if (oldUrlStateString != null && newUrlStateString != null) {
let newTimeline = null;
let oldTimeline = null;
try {
newTimeline = decodeRisonUrlState<TimelineUrl>(newUrlStateString);
} catch (error) {
// do nothing as timeline is defaulted to null
}
try {
oldTimeline = decodeRisonUrlState<TimelineUrl>(oldUrlStateString);
} catch (error) {
// do nothing as timeline is defaulted to null
}
const newId = newTimeline?.id;
const oldId = oldTimeline?.id;
if (newId && newId !== oldId && newId !== timelineIdFromReduxStore) {
queryTimelineById({
activeTimelineTab: newTimeline?.activeTab ?? TimelineTabs.query,
duplicate: false,
graphEventId: newTimeline?.graphEventId,
timelineId: newId,
openTimeline: true,
updateIsLoading: updateTimelineIsLoading,
updateTimeline,
});
}
}
};

View file

@ -79,6 +79,7 @@ export interface PreviousLocationUrlState {
pathName: string | undefined;
pageName: string | undefined;
urlState: UrlState;
search: string | undefined;
}
export interface UrlStateToRedux {

View file

@ -39,6 +39,7 @@ import {
} from './types';
import { TimelineUrl } from '../../../timelines/store/timeline/model';
import { UrlInputsModel } from '../../store/inputs/model';
import { queryTimelineByIdOnUrlChange } from './query_timeline_by_id_on_url_change';
function usePrevious(value: PreviousLocationUrlState) {
const ref = useRef<PreviousLocationUrlState>(value);
@ -60,9 +61,10 @@ export const useUrlStateHooks = ({
const [isFirstPageLoad, setIsFirstPageLoad] = useState(true);
const { filterManager, savedQueries } = useKibana().services.data.query;
const { pathname: browserPathName } = useLocation();
const prevProps = usePrevious({ pathName, pageName, urlState });
const prevProps = usePrevious({ pathName, pageName, urlState, search });
const setInitialStateFromUrl = useSetInitialStateFromUrl();
const { setInitialStateFromUrl, updateTimeline, updateTimelineIsLoading } =
useSetInitialStateFromUrl();
const handleInitialize = useCallback(
(type: UrlStateType) => {
@ -190,6 +192,16 @@ export const useUrlStateHooks = ({
document.title = `${getTitle(pageName, navTabs)} - Kibana`;
}, [pageName, navTabs]);
useEffect(() => {
queryTimelineByIdOnUrlChange({
oldSearch: prevProps.search,
search,
timelineIdFromReduxStore: urlState.timeline.id,
updateTimeline,
updateTimelineIsLoading,
});
}, [search, prevProps.search, urlState.timeline.id, updateTimeline, updateTimelineIsLoading]);
return null;
};

View file

@ -0,0 +1,166 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useLocation } from 'react-router-dom';
import { renderHook } from '@testing-library/react-hooks';
import { useDeepEqualSelector } from './use_selector';
import { useKibana } from '../lib/kibana';
import { useResolveConflict } from './use_resolve_conflict';
import * as urlHelpers from '../components/url_state/helpers';
jest.mock('react-router-dom', () => {
const original = jest.requireActual('react-router-dom');
return {
...original,
useLocation: jest.fn(),
};
});
jest.mock('../lib/kibana');
jest.mock('./use_selector');
jest.mock('../../timelines/store/timeline/', () => ({
timelineSelectors: {
getTimelineByIdSelector: () => jest.fn(),
},
}));
describe('useResolveConflict', () => {
const mockGetLegacyUrlConflict = jest.fn().mockReturnValue('Test!');
beforeEach(() => {
jest.resetAllMocks();
// Mock rison format in actual url
(useLocation as jest.Mock).mockReturnValue({
pathname: 'my/cool/path',
search:
'timeline=(activeTab:query,graphEventId:%27%27,id:%2704e8ffb0-2c2a-11ec-949c-39005af91f70%27,isOpen:!t)',
});
(useKibana as jest.Mock).mockReturnValue({
services: {
spaces: {
ui: {
components: {
getLegacyUrlConflict: mockGetLegacyUrlConflict,
},
},
},
},
});
});
afterEach(() => {
jest.clearAllMocks();
});
describe('resolve object is not provided', () => {
it('should not show the conflict message', async () => {
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
savedObjectId: 'current-saved-object-id',
activeTab: 'some-tab',
graphEventId: 'current-graph-event-id',
show: false,
}));
const { result } = renderHook<{}, JSX.Element | null>(() => useResolveConflict());
expect(mockGetLegacyUrlConflict).not.toHaveBeenCalled();
expect(result.current).toEqual(null);
});
});
describe('outcome is exactMatch', () => {
it('should not show the conflict message', async () => {
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
resolveTimelineConfig: {
outcome: 'exactMatch',
},
savedObjectId: 'current-saved-object-id',
activeTab: 'some-tab',
graphEventId: 'current-graph-event-id',
show: false,
}));
const { result } = renderHook<{}, JSX.Element | null>(() => useResolveConflict());
expect(mockGetLegacyUrlConflict).not.toHaveBeenCalled();
expect(result.current).toEqual(null);
});
});
describe('outcome is aliasMatch', () => {
it('should not show the conflict message', async () => {
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
resolveTimelineConfig: {
outcome: 'aliasMatch',
alias_target_id: 'new-id',
},
}));
const { result } = renderHook<{}, JSX.Element | null>(() => useResolveConflict());
expect(mockGetLegacyUrlConflict).not.toHaveBeenCalled();
expect(result.current).toEqual(null);
});
});
describe('outcome is conflict', () => {
const mockTextContent = 'I am the visible conflict message';
it('should show the conflict message', async () => {
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
resolveTimelineConfig: {
outcome: 'conflict',
alias_target_id: 'new-id',
},
}));
mockGetLegacyUrlConflict.mockImplementation(() => mockTextContent);
const { result } = renderHook<{}, JSX.Element | null>(() => useResolveConflict());
expect(mockGetLegacyUrlConflict).toHaveBeenCalledWith({
objectNoun: 'timeline',
currentObjectId: '04e8ffb0-2c2a-11ec-949c-39005af91f70',
otherObjectId: 'new-id',
otherObjectPath:
'my/cool/path?timeline=%28activeTab%3Aquery%2CgraphEventId%3A%27%27%2Cid%3Anew-id%2CisOpen%3A%21t%29',
});
expect(result.current).toMatchInlineSnapshot(`
<React.Fragment>
I am the visible conflict message
<EuiSpacer />
</React.Fragment>
`);
});
describe('rison is unable to be decoded', () => {
it('should use timeline values from redux to create the otherObjectPath', async () => {
jest.spyOn(urlHelpers, 'decodeRisonUrlState').mockImplementation(() => {
throw new Error('Unable to decode');
});
(useLocation as jest.Mock).mockReturnValue({
pathname: 'my/cool/path',
search: '?foo=bar',
});
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
resolveTimelineConfig: {
outcome: 'conflict',
alias_target_id: 'new-id',
},
savedObjectId: 'current-saved-object-id',
activeTab: 'some-tab',
graphEventId: 'current-graph-event-id',
show: false,
}));
mockGetLegacyUrlConflict.mockImplementation(() => mockTextContent);
renderHook(() => useResolveConflict());
const { result } = renderHook<{}, JSX.Element | null>(() => useResolveConflict());
expect(mockGetLegacyUrlConflict).toHaveBeenCalledWith({
objectNoun: 'timeline',
currentObjectId: 'current-saved-object-id',
otherObjectId: 'new-id',
otherObjectPath:
'my/cool/path?foo=bar&timeline=%28activeTab%3Asome-tab%2CgraphEventId%3Acurrent-graph-event-id%2Cid%3Anew-id%2CisOpen%3A%21f%29',
});
expect(result.current).toMatchInlineSnapshot(`
<React.Fragment>
I am the visible conflict message
<EuiSpacer />
</React.Fragment>
`);
});
});
});
});

View file

@ -0,0 +1,100 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { EuiSpacer } from '@elastic/eui';
import { useDeepEqualSelector } from './use_selector';
import { TimelineId } from '../../../common/types/timeline';
import { timelineSelectors } from '../../timelines/store/timeline';
import { TimelineUrl } from '../../timelines/store/timeline/model';
import { timelineDefaults } from '../../timelines/store/timeline/defaults';
import { decodeRisonUrlState, encodeRisonUrlState } from '../components/url_state/helpers';
import { useKibana } from '../lib/kibana';
import { CONSTANTS } from '../components/url_state/constants';
/**
* Unfortunately the url change initiated when clicking the button to otherObjectPath doesn't seem to be
* respected by the useSetInitialStateFromUrl here: x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx
*
* FYI: It looks like the routing causes replaceStateInLocation to be called instead:
* x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts
*
* Potentially why the markdown component needs a click handler as well for timeline?
* see: /x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/processor.tsx
*/
export const useResolveConflict = () => {
const { search, pathname } = useLocation();
const { spaces } = useKibana().services;
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const { resolveTimelineConfig, savedObjectId, show, graphEventId, activeTab } =
useDeepEqualSelector((state) => getTimeline(state, TimelineId.active) ?? timelineDefaults);
const getLegacyUrlConflictCallout = useCallback(() => {
// This function returns a callout component *if* we have encountered a "legacy URL conflict" scenario
if (
!spaces ||
resolveTimelineConfig?.outcome !== 'conflict' ||
resolveTimelineConfig?.alias_target_id == null
) {
return null;
}
const searchQuery = new URLSearchParams(search);
const timelineRison = searchQuery.get(CONSTANTS.timeline) ?? undefined;
// Try to get state on URL, but default to what's in Redux in case of decodeRisonFailure
const currentTimelineState = {
id: savedObjectId ?? '',
isOpen: !!show,
activeTab,
graphEventId,
};
let timelineSearch: TimelineUrl = currentTimelineState;
try {
timelineSearch = decodeRisonUrlState(timelineRison) ?? currentTimelineState;
} catch (error) {
// do nothing as it's already defaulted on line 77
}
// We have resolved to one object, but another object has a legacy URL alias associated with this ID/page. We should display a
// callout with a warning for the user, and provide a way for them to navigate to the other object.
const currentObjectId = timelineSearch?.id;
const newSavedObjectId = resolveTimelineConfig?.alias_target_id ?? ''; // This is always defined if outcome === 'conflict'
const newTimelineSearch: TimelineUrl = {
...timelineSearch,
id: newSavedObjectId,
};
const newTimelineRison = encodeRisonUrlState(newTimelineSearch);
searchQuery.set(CONSTANTS.timeline, newTimelineRison);
const newPath = `${pathname}?${searchQuery.toString()}${window.location.hash}`;
return (
<>
{spaces.ui.components.getLegacyUrlConflict({
objectNoun: CONSTANTS.timeline,
currentObjectId,
otherObjectId: newSavedObjectId,
otherObjectPath: newPath,
})}
<EuiSpacer />
</>
);
}, [
activeTab,
graphEventId,
pathname,
resolveTimelineConfig?.alias_target_id,
resolveTimelineConfig?.outcome,
savedObjectId,
search,
show,
spaces,
]);
return useMemo(() => getLegacyUrlConflictCallout(), [getLegacyUrlConflictCallout]);
};

View file

@ -0,0 +1,140 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useLocation } from 'react-router-dom';
import { renderHook } from '@testing-library/react-hooks';
import { useDeepEqualSelector } from './use_selector';
import { useKibana } from '../lib/kibana';
import { useResolveRedirect } from './use_resolve_redirect';
import * as urlHelpers from '../components/url_state/helpers';
jest.mock('react-router-dom', () => {
const original = jest.requireActual('react-router-dom');
return {
...original,
useLocation: jest.fn(),
};
});
jest.mock('../lib/kibana');
jest.mock('./use_selector');
jest.mock('../../timelines/store/timeline/', () => ({
timelineSelectors: {
getTimelineByIdSelector: () => jest.fn(),
},
}));
describe('useResolveRedirect', () => {
const mockRedirectLegacyUrl = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
// Mock rison format in actual url
(useLocation as jest.Mock).mockReturnValue({
pathname: 'my/cool/path',
search:
'timeline=(activeTab:query,graphEventId:%27%27,id:%2704e8ffb0-2c2a-11ec-949c-39005af91f70%27,isOpen:!t)',
});
(useKibana as jest.Mock).mockReturnValue({
services: {
spaces: {
ui: {
redirectLegacyUrl: mockRedirectLegacyUrl,
},
},
},
});
});
afterEach(() => {
jest.clearAllMocks();
});
describe('resolve object is not provided', () => {
it('should not redirect', async () => {
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
savedObjectId: 'current-saved-object-id',
activeTab: 'some-tab',
graphEventId: 'current-graph-event-id',
show: false,
}));
renderHook(() => useResolveRedirect());
expect(mockRedirectLegacyUrl).not.toHaveBeenCalled();
});
});
describe('outcome is exactMatch', () => {
it('should not redirect', async () => {
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
resolveTimelineConfig: {
outcome: 'exactMatch',
},
savedObjectId: 'current-saved-object-id',
activeTab: 'some-tab',
graphEventId: 'current-graph-event-id',
show: false,
}));
renderHook(() => useResolveRedirect());
expect(mockRedirectLegacyUrl).not.toHaveBeenCalled();
});
});
describe('outcome is aliasMatch', () => {
it('should redirect to url with id:new-id if outcome is aliasMatch', async () => {
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
resolveTimelineConfig: {
outcome: 'aliasMatch',
alias_target_id: 'new-id',
},
}));
renderHook(() => useResolveRedirect());
expect(mockRedirectLegacyUrl).toHaveBeenCalledWith(
'my/cool/path?timeline=%28activeTab%3Aquery%2CgraphEventId%3A%27%27%2Cid%3Anew-id%2CisOpen%3A%21t%29',
'timeline'
);
});
describe('rison is unable to be decoded', () => {
it('should use timeline values from redux to create the redirect path', async () => {
jest.spyOn(urlHelpers, 'decodeRisonUrlState').mockImplementation(() => {
throw new Error('Unable to decode');
});
(useLocation as jest.Mock).mockReturnValue({
pathname: 'my/cool/path',
search: '?foo=bar',
});
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
resolveTimelineConfig: {
outcome: 'aliasMatch',
alias_target_id: 'new-id',
},
savedObjectId: 'current-saved-object-id',
activeTab: 'some-tab',
graphEventId: 'current-graph-event-id',
show: false,
}));
renderHook(() => useResolveRedirect());
expect(mockRedirectLegacyUrl).toHaveBeenCalledWith(
'my/cool/path?foo=bar&timeline=%28activeTab%3Asome-tab%2CgraphEventId%3Acurrent-graph-event-id%2Cid%3Anew-id%2CisOpen%3A%21f%29',
'timeline'
);
});
});
});
describe('outcome is conflict', () => {
it('should not redirect', async () => {
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
resolveTimelineConfig: {
outcome: 'conflict',
alias_target_id: 'new-id',
},
}));
renderHook(() => useResolveRedirect());
expect(mockRedirectLegacyUrl).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useDeepEqualSelector } from './use_selector';
import { TimelineId } from '../../../common/types/timeline';
import { timelineSelectors } from '../../timelines/store/timeline/';
import { timelineDefaults } from '../../timelines/store/timeline/defaults';
import { decodeRisonUrlState, encodeRisonUrlState } from '../components/url_state/helpers';
import { useKibana } from '../lib/kibana';
import { TimelineUrl } from '../../timelines/store/timeline/model';
import { CONSTANTS } from '../components/url_state/constants';
/**
* This hooks is specifically for use with the resolve api that was introduced as part of 7.16
* If a deep link id has been migrated to a new id, this hook will cause a redirect to a url with
* the new ID.
*/
export const useResolveRedirect = () => {
const { search, pathname } = useLocation();
const [hasRedirected, updateHasRedirected] = useState(false);
const { spaces } = useKibana().services;
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const { resolveTimelineConfig, savedObjectId, show, activeTab, graphEventId } =
useDeepEqualSelector((state) => getTimeline(state, TimelineId.active) ?? timelineDefaults);
const redirect = useCallback(() => {
const searchQuery = new URLSearchParams(search);
const timelineRison = searchQuery.get(CONSTANTS.timeline) ?? undefined;
// Try to get state on URL, but default to what's in Redux in case of decodeRisonFailure
const currentTimelineState = {
id: savedObjectId ?? '',
isOpen: !!show,
activeTab,
graphEventId,
};
let timelineSearch: TimelineUrl = currentTimelineState;
try {
timelineSearch = decodeRisonUrlState(timelineRison) ?? currentTimelineState;
} catch (error) {
// do nothing as it's already defaulted on line 77
}
if (
hasRedirected ||
!spaces ||
resolveTimelineConfig?.outcome !== 'aliasMatch' ||
resolveTimelineConfig?.alias_target_id == null
) {
return null;
}
// We found this object by a legacy URL alias from its old ID; redirect the user to the page with its new ID, preserving any URL hash
const newObjectId = resolveTimelineConfig?.alias_target_id ?? ''; // This is always defined if outcome === 'aliasMatch'
const newTimelineSearch = {
...timelineSearch,
id: newObjectId,
};
const newTimelineRison = encodeRisonUrlState(newTimelineSearch);
searchQuery.set(CONSTANTS.timeline, newTimelineRison);
const newPath = `${pathname}?${searchQuery.toString()}`;
spaces.ui.redirectLegacyUrl(newPath, CONSTANTS.timeline);
// Prevent the effect from being called again as the url change takes place in location rather than a true redirect
updateHasRedirected(true);
}, [
activeTab,
graphEventId,
hasRedirected,
pathname,
resolveTimelineConfig?.outcome,
resolveTimelineConfig?.alias_target_id,
savedObjectId,
search,
show,
spaces,
]);
useEffect(() => {
redirect();
}, [redirect]);
};

View file

@ -9,7 +9,7 @@ import { TimelineStatus, TimelineType } from '../../../../../common/types/timeli
export const mockTimeline = {
data: {
getOneTimeline: {
timeline: {
savedObjectId: 'eb2781c0-1df5-11eb-8589-2f13958b79f7',
columns: [
{
@ -163,6 +163,7 @@ export const mockTimeline = {
version: 'WzQ4NSwxXQ==',
__typename: 'TimelineResult',
},
outcome: 'exactMatch',
},
loading: false,
networkStatus: 7,
@ -171,7 +172,7 @@ export const mockTimeline = {
export const mockTemplate = {
data: {
getOneTimeline: {
timeline: {
savedObjectId: '0c70a200-1de0-11eb-885c-6fc13fca1850',
columns: [
{
@ -416,6 +417,7 @@ export const mockTemplate = {
version: 'WzQwMywxXQ==',
__typename: 'TimelineResult',
},
outcome: 'exactMatch',
},
loading: false,
networkStatus: 7,

View file

@ -50,7 +50,7 @@ import {
mockTimeline as mockSelectedTimeline,
mockTemplate as mockSelectedTemplate,
} from './__mocks__';
import { getTimeline } from '../../containers/api';
import { resolveTimeline } from '../../containers/api';
import { defaultHeaders } from '../timeline/body/column_headers/default_headers';
jest.mock('../../../common/store/inputs/actions');
@ -951,7 +951,7 @@ describe('helpers', () => {
};
beforeAll(async () => {
(getTimeline as jest.Mock).mockRejectedValue(mockError);
(resolveTimeline as jest.Mock).mockRejectedValue(mockError);
queryTimelineById<{}>(args as unknown as QueryTimelineById<{}>);
});
@ -986,7 +986,7 @@ describe('helpers', () => {
};
beforeAll(async () => {
(getTimeline as jest.Mock).mockResolvedValue(selectedTimeline);
(resolveTimeline as jest.Mock).mockResolvedValue(selectedTimeline);
await queryTimelineById<{}>(args as unknown as QueryTimelineById<{}>);
});
@ -1002,7 +1002,7 @@ describe('helpers', () => {
});
test('get timeline by Id', () => {
expect(getTimeline).toHaveBeenCalled();
expect(resolveTimeline).toHaveBeenCalled();
});
test('it does not call onError when an error does not occur', () => {
@ -1011,7 +1011,7 @@ describe('helpers', () => {
test('Do not override daterange if TimelineStatus is active', () => {
const { timeline } = formatTimelineResultToModel(
omitTypenameInTimeline(getOr({}, 'data.getOneTimeline', selectedTimeline)),
omitTypenameInTimeline(getOr({}, 'data.timeline', selectedTimeline)),
args.duplicate,
args.timelineType
);
@ -1044,7 +1044,7 @@ describe('helpers', () => {
};
beforeAll(async () => {
(getTimeline as jest.Mock).mockResolvedValue(selectedTimeline);
(resolveTimeline as jest.Mock).mockResolvedValue(selectedTimeline);
await queryTimelineById<{}>(args as unknown as QueryTimelineById<{}>);
});
@ -1060,12 +1060,12 @@ describe('helpers', () => {
});
test('get timeline by Id', () => {
expect(getTimeline).toHaveBeenCalled();
expect(resolveTimeline).toHaveBeenCalled();
});
test('should not override daterange if TimelineStatus is active', () => {
const { timeline } = formatTimelineResultToModel(
omitTypenameInTimeline(getOr({}, 'data.getOneTimeline', selectedTimeline)),
omitTypenameInTimeline(getOr({}, 'data.timeline', selectedTimeline)),
args.duplicate,
args.timelineType
);
@ -1085,6 +1085,10 @@ describe('helpers', () => {
to: '2020-07-08T08:20:18.966Z',
notes: [],
id: TimelineId.active,
resolveTimelineConfig: {
outcome: 'exactMatch',
alias_target_id: undefined,
},
});
});
@ -1112,12 +1116,12 @@ describe('helpers', () => {
};
beforeAll(async () => {
(getTimeline as jest.Mock).mockResolvedValue(template);
(resolveTimeline as jest.Mock).mockResolvedValue(template);
await queryTimelineById<{}>(args as unknown as QueryTimelineById<{}>);
});
afterAll(() => {
(getTimeline as jest.Mock).mockReset();
(resolveTimeline as jest.Mock).mockReset();
jest.clearAllMocks();
});
@ -1129,12 +1133,12 @@ describe('helpers', () => {
});
test('get timeline by Id', () => {
expect(getTimeline).toHaveBeenCalled();
expect(resolveTimeline).toHaveBeenCalled();
});
test('override daterange if TimelineStatus is immutable', () => {
const { timeline } = formatTimelineResultToModel(
omitTypenameInTimeline(getOr({}, 'data.getOneTimeline', template)),
omitTypenameInTimeline(getOr({}, 'data.timeline', template)),
args.duplicate,
args.timelineType
);

View file

@ -20,6 +20,7 @@ import {
TimelineType,
TimelineTabs,
TimelineResult,
SingleTimelineResolveResponse,
ColumnHeaderResult,
FilterTimelineResult,
DataProviderResult,
@ -65,7 +66,7 @@ import {
DEFAULT_FROM_MOMENT,
DEFAULT_TO_MOMENT,
} from '../../../common/utils/default_date_settings';
import { getTimeline } from '../../containers/api';
import { resolveTimeline } from '../../containers/api';
import { PinnedEvent } from '../../../../common/types/timeline/pinned_event';
import { NoteResult } from '../../../../common/types/timeline/note';
@ -346,11 +347,12 @@ export const queryTimelineById = <TCache>({
updateTimeline,
}: QueryTimelineById<TCache>) => {
updateIsLoading({ id: TimelineId.active, isLoading: true });
Promise.resolve(getTimeline(timelineId))
Promise.resolve(resolveTimeline(timelineId))
.then((result) => {
const timelineToOpen: TimelineResult = omitTypenameInTimeline(
getOr({}, 'data.getOneTimeline', result)
);
const data: SingleTimelineResolveResponse['data'] | null = getOr(null, 'data', result);
if (!data) return;
const timelineToOpen = omitTypenameInTimeline(data.timeline);
const { timeline, notes } = formatTimelineResultToModel(
timelineToOpen,
@ -370,6 +372,10 @@ export const queryTimelineById = <TCache>({
from,
id: TimelineId.active,
notes,
resolveTimelineConfig: {
outcome: data.outcome,
alias_target_id: data.alias_target_id,
},
timeline: {
...timeline,
activeTab: activeTimelineTab,
@ -399,6 +405,7 @@ export const dispatchUpdateTimeline =
forceNotes = false,
from,
notes,
resolveTimelineConfig,
timeline,
to,
ruleNote,
@ -429,7 +436,9 @@ export const dispatchUpdateTimeline =
} else {
dispatch(dispatchSetTimelineRangeDatePicker({ from, to }));
}
dispatch(dispatchAddTimeline({ id, timeline, savedTimeline: duplicate }));
dispatch(
dispatchAddTimeline({ id, timeline, resolveTimelineConfig, savedTimeline: duplicate })
);
if (
timeline.kqlQuery != null &&
timeline.kqlQuery.filterQuery != null &&

View file

@ -16,6 +16,7 @@ import {
TemplateTimelineTypeLiteral,
RowRendererId,
TimelineStatusLiteralWithNull,
SingleTimelineResolveResponse,
} from '../../../../common/types/timeline';
/** The users who added a timeline to favorites */
@ -194,12 +195,17 @@ export interface OpenTimelineProps {
hideActions?: ActionTimelineToShow[];
}
export interface ResolveTimelineConfig {
alias_target_id: SingleTimelineResolveResponse['data']['alias_target_id'];
outcome: SingleTimelineResolveResponse['data']['outcome'];
}
export interface UpdateTimeline {
duplicate: boolean;
id: string;
forceNotes?: boolean;
from: string;
notes: NoteResult[] | null | undefined;
resolveTimelineConfig?: ResolveTimelineConfig;
timeline: TimelineModel;
to: string;
ruleNote?: string;
@ -210,6 +216,7 @@ export type DispatchUpdateTimeline = ({
id,
from,
notes,
resolveTimelineConfig,
timeline,
to,
ruleNote,

View file

@ -40,6 +40,12 @@ const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock;
jest.mock('use-resize-observer/polyfilled');
mockUseResizeObserver.mockImplementation(() => ({}));
jest.mock('../../../common/hooks/use_resolve_conflict', () => {
return {
useResolveConflict: jest.fn().mockImplementation(() => null),
};
});
jest.mock('react-router-dom', () => {
const original = jest.requireActual('react-router-dom');

View file

@ -28,6 +28,7 @@ import { TabsContent } from './tabs_content';
import { HideShowContainer, TimelineContainer } from './styles';
import { useTimelineFullScreen } from '../../../common/containers/use_full_screen';
import { EXIT_FULL_SCREEN_CLASS_NAME } from '../../../common/components/exit_full_screen';
import { useResolveConflict } from '../../../common/hooks/use_resolve_conflict';
const TimelineTemplateBadge = styled.div`
background: ${({ theme }) => theme.eui.euiColorVis3_behindText};
@ -119,6 +120,7 @@ const StatefulTimelineComponent: React.FC<Props> = ({
[containerElement, onSkipFocusBeforeEventsTable, onSkipFocusAfterEventsTable]
);
const timelineContext = useMemo(() => ({ timelineId }), [timelineId]);
const resolveConflictComponent = useResolveConflict();
return (
<TimelineContext.Provider value={timelineContext}>
@ -132,7 +134,7 @@ const StatefulTimelineComponent: React.FC<Props> = ({
{timelineType === TimelineType.template && (
<TimelineTemplateBadge>{i18n.TIMELINE_TEMPLATE}</TimelineTemplateBadge>
)}
{resolveConflictComponent}
<HideShowContainer
$isVisible={!timelineFullScreen}
data-test-subj="timeline-hide-show-container"

View file

@ -338,19 +338,6 @@ export const getTimelineTemplate = async (templateTimelineId: string) => {
return decodeSingleTimelineResponse(response);
};
export const getResolvedTimelineTemplate = async (templateTimelineId: string) => {
const response = await KibanaServices.get().http.get<SingleTimelineResolveResponse>(
TIMELINE_RESOLVE_URL,
{
query: {
template_timeline_id: templateTimelineId,
},
}
);
return decodeResolvedSingleTimelineResponse(response);
};
export const getAllTimelines = async (args: GetTimelinesArgs, abortSignal: AbortSignal) => {
const response = await KibanaServices.get().http.fetch<AllTimelinesResponse>(TIMELINES_URL, {
method: 'GET',

View file

@ -25,6 +25,7 @@ import type {
SerializedFilterQuery,
} from '../../../../common/types/timeline';
import { tGridActions } from '../../../../../timelines/public';
import { ResolveTimelineConfig } from '../../components/open_timeline/types';
export const {
applyDeltaToColumnWidth,
clearEventsDeleted,
@ -91,6 +92,7 @@ export const updateTimeline = actionCreator<{
export const addTimeline = actionCreator<{
id: string;
timeline: TimelineModel;
resolveTimelineConfig?: ResolveTimelineConfig;
savedTimeline?: boolean;
}>('ADD_TIMELINE');

View file

@ -14,64 +14,65 @@ import { SubsetTimelineModel, TimelineModel } from './model';
// normalizeTimeRange uses getTimeRangeSettings which cannot be used outside Kibana context if the uiSettings is not false
const { from: start, to: end } = normalizeTimeRange({ from: '', to: '' }, false);
export const timelineDefaults: SubsetTimelineModel & Pick<TimelineModel, 'filters' | 'eqlOptions'> =
{
activeTab: TimelineTabs.query,
prevActiveTab: TimelineTabs.query,
columns: defaultHeaders,
documentType: '',
defaultColumns: defaultHeaders,
dataProviders: [],
dateRange: { start, end },
deletedEventIds: [],
description: '',
eqlOptions: {
eventCategoryField: 'event.category',
tiebreakerField: '',
timestampField: '@timestamp',
query: '',
size: 100,
export const timelineDefaults: SubsetTimelineModel &
Pick<TimelineModel, 'filters' | 'eqlOptions' | 'resolveTimelineConfig'> = {
activeTab: TimelineTabs.query,
prevActiveTab: TimelineTabs.query,
columns: defaultHeaders,
documentType: '',
defaultColumns: defaultHeaders,
dataProviders: [],
dateRange: { start, end },
deletedEventIds: [],
description: '',
eqlOptions: {
eventCategoryField: 'event.category',
tiebreakerField: '',
timestampField: '@timestamp',
query: '',
size: 100,
},
eventType: 'all',
eventIdToNoteIds: {},
excludedRowRendererIds: [],
expandedDetail: {},
highlightedDropAndProviderId: '',
historyIds: [],
filters: [],
indexNames: [],
isFavorite: false,
isLive: false,
isSelectAllChecked: false,
isLoading: false,
isSaving: false,
itemsPerPage: 25,
itemsPerPageOptions: [10, 25, 50, 100],
kqlMode: 'filter',
kqlQuery: {
filterQuery: null,
},
loadingEventIds: [],
resolveTimelineConfig: undefined,
queryFields: [],
title: '',
timelineType: TimelineType.default,
templateTimelineId: null,
templateTimelineVersion: null,
noteIds: [],
pinnedEventIds: {},
pinnedEventsSaveObject: {},
savedObjectId: null,
selectAll: false,
selectedEventIds: {},
show: false,
showCheckboxes: false,
sort: [
{
columnId: '@timestamp',
columnType: 'number',
sortDirection: 'desc',
},
eventType: 'all',
eventIdToNoteIds: {},
excludedRowRendererIds: [],
expandedDetail: {},
highlightedDropAndProviderId: '',
historyIds: [],
filters: [],
indexNames: [],
isFavorite: false,
isLive: false,
isSelectAllChecked: false,
isLoading: false,
isSaving: false,
itemsPerPage: 25,
itemsPerPageOptions: [10, 25, 50, 100],
kqlMode: 'filter',
kqlQuery: {
filterQuery: null,
},
loadingEventIds: [],
queryFields: [],
title: '',
timelineType: TimelineType.default,
templateTimelineId: null,
templateTimelineVersion: null,
noteIds: [],
pinnedEventIds: {},
pinnedEventsSaveObject: {},
savedObjectId: null,
selectAll: false,
selectedEventIds: {},
show: false,
showCheckboxes: false,
sort: [
{
columnId: '@timestamp',
columnType: 'number',
sortDirection: 'desc',
},
],
status: TimelineStatus.draft,
version: null,
};
],
status: TimelineStatus.draft,
version: null,
};

View file

@ -47,6 +47,7 @@ import {
RESIZED_COLUMN_MIN_WITH,
} from '../../components/timeline/body/constants';
import { activeTimeline } from '../../containers/active_timeline_context';
import { ResolveTimelineConfig } from '../../components/open_timeline/types';
export const isNotNull = <T>(value: T | null): value is T => value !== null;
@ -124,6 +125,7 @@ export const addTimelineNoteToEvent = ({
interface AddTimelineParams {
id: string;
resolveTimelineConfig?: ResolveTimelineConfig;
timeline: TimelineModel;
timelineById: TimelineById;
}
@ -145,6 +147,7 @@ export const shouldResetActiveTimelineContext = (
*/
export const addTimelineToStore = ({
id,
resolveTimelineConfig,
timeline,
timelineById,
}: AddTimelineParams): TimelineById => {
@ -159,6 +162,7 @@ export const addTimelineToStore = ({
filterManager: timelineById[id].filterManager,
isLoading: timelineById[id].isLoading,
initialized: timelineById[id].initialized,
resolveTimelineConfig,
dateRange:
timeline.status === TimelineStatus.immutable &&
timeline.timelineType === TimelineType.template

View file

@ -15,6 +15,7 @@ import type {
} from '../../../../common/types/timeline';
import { PinnedEvent } from '../../../../common/types/timeline/pinned_event';
import type { TGridModelForTimeline } from '../../../../../timelines/public';
import { ResolveTimelineConfig } from '../../components/open_timeline/types';
export const DEFAULT_PAGE_COUNT = 2; // Eui Pager will not render unless this is a minimum of 2 pages
export type KqlMode = 'filter' | 'search';
@ -59,6 +60,7 @@ export type TimelineModel = TGridModelForTimeline & {
/** Events pinned to this timeline */
pinnedEventIds: Record<string, boolean>;
pinnedEventsSaveObject: Record<string, PinnedEvent>;
resolveTimelineConfig?: ResolveTimelineConfig;
showSaveModal?: boolean;
savedQueryId?: string | null;
/** When true, show the timeline flyover */

View file

@ -94,9 +94,14 @@ export const initialTimelineState: TimelineState = {
/** The reducer for all timeline actions */
export const timelineReducer = reducerWithInitialState(initialTimelineState)
.case(addTimeline, (state, { id, timeline }) => ({
.case(addTimeline, (state, { id, timeline, resolveTimelineConfig }) => ({
...state,
timelineById: addTimelineToStore({ id, timeline, timelineById: state.timelineById }),
timelineById: addTimelineToStore({
id,
timeline,
resolveTimelineConfig,
timelineById: state.timelineById,
}),
}))
.case(createTimeline, (state, { id, timelineType = TimelineType.default, ...timelineProps }) => {
return {

View file

@ -9,7 +9,6 @@ import { CoreStart } from '../../../../src/core/public';
import { HomePublicPluginSetup } from '../../../../src/plugins/home/public';
import { DataPublicPluginStart } from '../../../../src/plugins/data/public';
import { EmbeddableStart } from '../../../../src/plugins/embeddable/public';
import { SpacesPluginStart } from '../../../plugins/spaces/public';
import { LensPublicStart } from '../../../plugins/lens/public';
import { NewsfeedPublicPluginStart } from '../../../../src/plugins/newsfeed/public';
import { Start as InspectorStart } from '../../../../src/plugins/inspector/public';
@ -18,6 +17,7 @@ import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/p
import { Storage } from '../../../../src/plugins/kibana_utils/public';
import { FleetStart } from '../../fleet/public';
import { PluginStart as ListsPluginStart } from '../../lists/public';
import { SpacesPluginStart } from '../../spaces/public';
import {
TriggersAndActionsUIPublicPluginSetup as TriggersActionsSetup,
TriggersAndActionsUIPublicPluginStart as TriggersActionsStart,

View file

@ -1,4 +1,3 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
@ -40,7 +39,7 @@
{ "path": "../maps/tsconfig.json" },
{ "path": "../ml/tsconfig.json" },
{ "path": "../spaces/tsconfig.json" },
{ "path": "../security/tsconfig.json"},
{ "path": "../timelines/tsconfig.json"},
{ "path": "../security/tsconfig.json" },
{ "path": "../timelines/tsconfig.json" }
]
}