diff --git a/x-pack/plugins/siem/public/components/link_to/index.ts b/x-pack/plugins/siem/public/components/link_to/index.ts index a1c1f78e398e33..c35a60766d7bd9 100644 --- a/x-pack/plugins/siem/public/components/link_to/index.ts +++ b/x-pack/plugins/siem/public/components/link_to/index.ts @@ -12,7 +12,11 @@ export { export { getOverviewUrl, RedirectToOverviewPage } from './redirect_to_overview'; export { getHostDetailsUrl, getHostsUrl } from './redirect_to_hosts'; export { getNetworkUrl, getIPDetailsUrl, RedirectToNetworkPage } from './redirect_to_network'; -export { getTimelinesUrl, RedirectToTimelinesPage } from './redirect_to_timelines'; +export { + getTimelinesUrl, + getTimelineTabsUrl, + RedirectToTimelinesPage, +} from './redirect_to_timelines'; export { getCaseDetailsUrl, getCaseUrl, diff --git a/x-pack/plugins/siem/public/components/link_to/link_to.tsx b/x-pack/plugins/siem/public/components/link_to/link_to.tsx index 08e4d1a3494e06..d3bf2e34b435b7 100644 --- a/x-pack/plugins/siem/public/components/link_to/link_to.tsx +++ b/x-pack/plugins/siem/public/components/link_to/link_to.tsx @@ -26,6 +26,7 @@ import { RedirectToConfigureCasesPage, } from './redirect_to_case'; import { DetectionEngineTab } from '../../pages/detection_engine/types'; +import { TimelineType } from '../../../common/types/timeline'; interface LinkToPageProps { match: RouteMatch<{}>; @@ -112,8 +113,13 @@ export const LinkToPage = React.memo(({ match }) => ( /> + )); diff --git a/x-pack/plugins/siem/public/components/link_to/redirect_to_timelines.tsx b/x-pack/plugins/siem/public/components/link_to/redirect_to_timelines.tsx index 27765a4125afcd..9c704a7f70d293 100644 --- a/x-pack/plugins/siem/public/components/link_to/redirect_to_timelines.tsx +++ b/x-pack/plugins/siem/public/components/link_to/redirect_to_timelines.tsx @@ -11,14 +11,30 @@ import { SiemPageName } from '../../pages/home/types'; import { appendSearch } from './helpers'; import { RedirectWrapper } from './redirect_wrapper'; +import { TimelineTypeLiteral, TimelineType } from '../../../common/types/timeline'; export type TimelineComponentProps = RouteComponentProps<{ + tabName: TimelineTypeLiteral; search: string; }>; -export const RedirectToTimelinesPage = ({ location: { search } }: TimelineComponentProps) => ( - +export const RedirectToTimelinesPage = ({ + match: { + params: { tabName }, + }, + location: { search }, +}: TimelineComponentProps) => ( + ); export const getTimelinesUrl = (search?: string) => `#/link-to/${SiemPageName.timelines}${appendSearch(search)}`; + +export const getTimelineTabsUrl = (tabName: TimelineTypeLiteral, search?: string) => + `#/link-to/${SiemPageName.timelines}/${tabName}${appendSearch(search)}`; diff --git a/x-pack/plugins/siem/public/components/navigation/breadcrumbs/index.test.ts b/x-pack/plugins/siem/public/components/navigation/breadcrumbs/index.test.ts index 7770780fb9613e..2acae92c390dde 100644 --- a/x-pack/plugins/siem/public/components/navigation/breadcrumbs/index.test.ts +++ b/x-pack/plugins/siem/public/components/navigation/breadcrumbs/index.test.ts @@ -148,7 +148,7 @@ describe('Navigation Breadcrumbs', () => { ); expect(breadcrumbs).toEqual([ { text: 'SIEM', href: '#/link-to/overview' }, - { text: 'Timelines', href: '' }, + { text: 'Timelines', href: '#/link-to/timelines' }, ]); }); diff --git a/x-pack/plugins/siem/public/components/navigation/breadcrumbs/index.ts b/x-pack/plugins/siem/public/components/navigation/breadcrumbs/index.ts index 85d77485830a52..8abc099ee7f693 100644 --- a/x-pack/plugins/siem/public/components/navigation/breadcrumbs/index.ts +++ b/x-pack/plugins/siem/public/components/navigation/breadcrumbs/index.ts @@ -13,8 +13,14 @@ import { getBreadcrumbs as getHostDetailsBreadcrumbs } from '../../../pages/host import { getBreadcrumbs as getIPDetailsBreadcrumbs } from '../../../pages/network/ip_details'; import { getBreadcrumbs as getCaseDetailsBreadcrumbs } from '../../../pages/case/utils'; import { getBreadcrumbs as getDetectionRulesBreadcrumbs } from '../../../pages/detection_engine/rules/utils'; +import { getBreadcrumbs as getTimelinesBreadcrumbs } from '../../../pages/timelines'; import { SiemPageName } from '../../../pages/home/types'; -import { RouteSpyState, HostRouteSpyState, NetworkRouteSpyState } from '../../../utils/route/types'; +import { + RouteSpyState, + HostRouteSpyState, + NetworkRouteSpyState, + TimelineRouteSpyState, +} from '../../../utils/route/types'; import { getOverviewUrl } from '../../link_to'; import { TabNavigationProps } from '../tab_navigation/types'; @@ -44,6 +50,9 @@ const isNetworkRoutes = (spyState: RouteSpyState): spyState is NetworkRouteSpySt const isHostsRoutes = (spyState: RouteSpyState): spyState is HostRouteSpyState => spyState != null && spyState.pageName === SiemPageName.hosts; +const isTimelinesRoutes = (spyState: RouteSpyState): spyState is TimelineRouteSpyState => + spyState != null && spyState.pageName === SiemPageName.timelines; + const isCaseRoutes = (spyState: RouteSpyState): spyState is RouteSpyState => spyState != null && spyState.pageName === SiemPageName.case; @@ -124,6 +133,24 @@ export const getBreadcrumbsForRoute = ( ), ]; } + if (isTimelinesRoutes(spyState) && object.navTabs) { + const tempNav: SearchNavTab = { urlKey: 'timeline', isDetailPage: false }; + let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; + if (spyState.tabName != null) { + urlStateKeys = [...urlStateKeys, getOr(tempNav, spyState.tabName, object.navTabs)]; + } + + return [ + ...siemRootBreadcrumb, + ...getTimelinesBreadcrumbs( + spyState, + urlStateKeys.reduce( + (acc: string[], item: SearchNavTab) => [...acc, getSearch(item, object)], + [] + ) + ), + ]; + } if ( spyState != null && object.navTabs && diff --git a/x-pack/plugins/siem/public/components/open_timeline/index.test.tsx b/x-pack/plugins/siem/public/components/open_timeline/index.test.tsx index ea28bc06ef915b..731c6d1ca9806a 100644 --- a/x-pack/plugins/siem/public/components/open_timeline/index.test.tsx +++ b/x-pack/plugins/siem/public/components/open_timeline/index.test.tsx @@ -17,16 +17,28 @@ import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../pages/timelines/timelines import { NotePreviews } from './note_previews'; import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; +import { TimelineTabsStyle } from './types'; + import { StatefulOpenTimeline } from '.'; import { useGetAllTimeline, getAllTimeline } from '../../containers/timeline/all'; jest.mock('../../lib/kibana'); jest.mock('../../containers/timeline/all', () => { const originalModule = jest.requireActual('../../containers/timeline/all'); return { + ...originalModule, useGetAllTimeline: jest.fn(), getAllTimeline: originalModule.getAllTimeline, }; }); +jest.mock('./use_timeline_types', () => { + return { + useTimelineTypes: jest.fn().mockReturnValue({ + timelineType: 'default', + timelineTabs:
, + timelineFilters:
, + }), + }; +}); describe('StatefulOpenTimeline', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); @@ -489,33 +501,30 @@ describe('StatefulOpenTimeline', () => { .text() ).toEqual('elastic'); }); - }); - test('it renders the title', async () => { - const wrapper = mount( - - - - - - - - ); + test('it renders the title', async () => { + const wrapper = mount( + + + + + + + + ); - await wait(); + await wait(); - expect( - wrapper - .find('[data-test-subj="header-section-title"]') - .first() - .text() - ).toEqual(title); + expect(wrapper.find(`[data-test-subj="timeline-${TimelineTabsStyle.tab}"]`).exists()).toEqual( + true + ); + }); }); describe('#resetSelectionState', () => { diff --git a/x-pack/plugins/siem/public/components/open_timeline/index.tsx b/x-pack/plugins/siem/public/components/open_timeline/index.tsx index d26d02780ffba8..ed22673f07a780 100644 --- a/x-pack/plugins/siem/public/components/open_timeline/index.tsx +++ b/x-pack/plugins/siem/public/components/open_timeline/index.tsx @@ -9,6 +9,7 @@ import React, { useEffect, useState, useCallback } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { Dispatch } from 'redux'; + import { defaultHeaders } from '../../components/timeline/body/column_headers/default_headers'; import { deleteTimelineMutation } from '../../containers/timeline/delete/persist.gql_query'; import { useGetAllTimeline } from '../../containers/timeline/all'; @@ -40,6 +41,7 @@ import { OnDeleteOneTimeline, } from './types'; import { DEFAULT_SORT_FIELD, DEFAULT_SORT_DIRECTION } from './constants'; +import { useTimelineTypes } from './use_timeline_types'; interface OwnProps { apolloClient: ApolloClient; @@ -103,7 +105,21 @@ export const StatefulOpenTimelineComponent = React.memo( /** The requested field to sort on */ const [sortField, setSortField] = useState(DEFAULT_SORT_FIELD); - const { fetchAllTimeline, timelines, loading, totalCount, refetch } = useGetAllTimeline(); + const { timelineType, timelineTabs, timelineFilters } = useTimelineTypes(); + const { fetchAllTimeline, timelines, loading, totalCount } = useGetAllTimeline(); + + const refetch = useCallback(() => { + fetchAllTimeline({ + pageInfo: { + pageIndex: pageIndex + 1, + pageSize, + }, + search, + sort: { sortField: sortField as SortFieldTimeline, sortOrder: sortDirection as Direction }, + onlyUserFavorite: onlyFavorites, + timelineType, + }); + }, [pageIndex, pageSize, search, sortField, sortDirection, timelineType, onlyFavorites]); /** Invoked when the user presses enters to submit the text in the search input */ const onQueryChange: OnQueryChange = useCallback((query: EuiSearchBarQuery) => { @@ -139,6 +155,7 @@ export const StatefulOpenTimelineComponent = React.memo( if (timelineIds.includes(timeline.savedObjectId || '')) { createNewTimeline({ id: 'timeline-1', columns: defaultHeaders, show: false }); } + await apolloClient.mutate< DeleteTimelineMutation.Mutation, DeleteTimelineMutation.Variables @@ -229,27 +246,8 @@ export const StatefulOpenTimelineComponent = React.memo( }, []); useEffect(() => { - fetchAllTimeline({ - pageInfo: { - pageIndex: pageIndex + 1, - pageSize, - }, - search, - sort: { sortField: sortField as SortFieldTimeline, sortOrder: sortDirection as Direction }, - onlyUserFavorite: onlyFavorites, - timelines, - totalCount, - }); - }, [ - pageIndex, - pageSize, - search, - sortField, - sortDirection, - timelines, - totalCount, - onlyFavorites, - ]); + refetch(); + }, [refetch]); return !isModal ? ( ( selectedItems={selectedItems} sortDirection={sortDirection} sortField={sortField} + tabs={timelineTabs} title={title} totalSearchResultsCount={totalCount} /> @@ -303,6 +302,7 @@ export const StatefulOpenTimelineComponent = React.memo( selectedItems={selectedItems} sortDirection={sortDirection} sortField={sortField} + tabs={timelineFilters} title={title} totalSearchResultsCount={totalCount} /> diff --git a/x-pack/plugins/siem/public/components/open_timeline/open_timeline.test.tsx b/x-pack/plugins/siem/public/components/open_timeline/open_timeline.test.tsx index e010d54d711c3e..449e1b169cea64 100644 --- a/x-pack/plugins/siem/public/components/open_timeline/open_timeline.test.tsx +++ b/x-pack/plugins/siem/public/components/open_timeline/open_timeline.test.tsx @@ -11,7 +11,7 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../pages/timelines/timelines_page'; -import { OpenTimelineResult } from './types'; +import { OpenTimelineResult, OpenTimelineProps } from './types'; import { TimelinesTableProps } from './timelines_table'; import { mockTimelineResults } from '../../mock/timeline_results'; import { OpenTimeline } from './open_timeline'; @@ -25,75 +25,41 @@ describe('OpenTimeline', () => { let mockResults: OpenTimelineResult[]; - beforeEach(() => { - mockResults = cloneDeep(mockTimelineResults); + const getDefaultTestProps = (mockSearchResults: OpenTimelineResult[]): OpenTimelineProps => ({ + deleteTimelines: jest.fn(), + defaultPageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, + isLoading: false, + itemIdToExpandedNotesRowMap: {}, + onAddTimelinesToFavorites: jest.fn(), + onDeleteSelected: jest.fn(), + onlyFavorites: false, + onOpenTimeline: jest.fn(), + onQueryChange: jest.fn(), + onSelectionChange: jest.fn(), + onTableChange: jest.fn(), + onToggleOnlyFavorites: jest.fn(), + onToggleShowNotes: jest.fn(), + pageIndex: 0, + pageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, + query: '', + searchResults: mockSearchResults, + selectedItems: [], + sortDirection: DEFAULT_SORT_DIRECTION, + sortField: DEFAULT_SORT_FIELD, + tabs:
, + title, + totalSearchResultsCount: mockSearchResults.length, }); - test('it renders the title row', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="title-row"]') - .first() - .exists() - ).toBe(true); + beforeEach(() => { + mockResults = cloneDeep(mockTimelineResults); }); test('it renders the search row', () => { + const defaultProps = getDefaultTestProps(mockResults); const wrapper = mountWithIntl( - + ); @@ -106,32 +72,10 @@ describe('OpenTimeline', () => { }); test('it renders the timelines table', () => { + const defaultProps = getDefaultTestProps(mockResults); const wrapper = mountWithIntl( - + ); @@ -144,32 +88,10 @@ describe('OpenTimeline', () => { }); test('it shows the delete action columns when onDeleteSelected and deleteTimelines are specified', () => { + const defaultProps = getDefaultTestProps(mockResults); const wrapper = mountWithIntl( - + ); @@ -182,31 +104,14 @@ describe('OpenTimeline', () => { }); test('it does NOT show the delete action columns when is onDeleteSelected undefined and deleteTimelines is specified', () => { + const defaultProps = { + ...getDefaultTestProps(mockResults), + onDeleteSelected: undefined, + deleteTimelines: undefined, + }; const wrapper = mountWithIntl( - + ); @@ -219,31 +124,14 @@ describe('OpenTimeline', () => { }); test('it does NOT show the delete action columns when is onDeleteSelected provided and deleteTimelines is undefined', () => { + const defaultProps = { + ...getDefaultTestProps(mockResults), + onDeleteSelected: undefined, + deleteTimelines: undefined, + }; const wrapper = mountWithIntl( - + ); @@ -256,30 +144,14 @@ describe('OpenTimeline', () => { }); test('it does NOT show the delete action when both onDeleteSelected and deleteTimelines are undefined', () => { + const defaultProps = { + ...getDefaultTestProps(mockResults), + onDeleteSelected: undefined, + deleteTimelines: undefined, + }; const wrapper = mountWithIntl( - + ); @@ -292,32 +164,13 @@ describe('OpenTimeline', () => { }); test('it renders an empty string when the query is an empty string', () => { + const defaultProps = { + ...getDefaultTestProps(mockResults), + query: '', + }; const wrapper = mountWithIntl( - + ); @@ -330,32 +183,13 @@ describe('OpenTimeline', () => { }); test('it renders the expected message when the query just has spaces', () => { + const defaultProps = { + ...getDefaultTestProps(mockResults), + query: ' ', + }; const wrapper = mountWithIntl( - + ); @@ -368,32 +202,13 @@ describe('OpenTimeline', () => { }); test('it echos the query when the query has non-whitespace characters', () => { + const defaultProps = { + ...getDefaultTestProps(mockResults), + query: 'Would you like to go to Denver?', + }; const wrapper = mountWithIntl( - + ); @@ -406,32 +221,13 @@ describe('OpenTimeline', () => { }); test('trims whitespace from the ends of the query', () => { + const defaultProps = { + ...getDefaultTestProps(mockResults), + query: ' Is it starting to feel cramped in here? ', + }; const wrapper = mountWithIntl( - + ); @@ -444,32 +240,13 @@ describe('OpenTimeline', () => { }); test('it renders the expected message when the query is an empty string', () => { + const defaultProps = { + ...getDefaultTestProps(mockResults), + query: '', + }; const wrapper = mountWithIntl( - + ); @@ -482,32 +259,13 @@ describe('OpenTimeline', () => { }); test('it renders the expected message when the query just has whitespace', () => { + const defaultProps = { + ...getDefaultTestProps(mockResults), + query: ' ', + }; const wrapper = mountWithIntl( - + ); @@ -520,32 +278,13 @@ describe('OpenTimeline', () => { }); test('it includes the word "with" when the query has non-whitespace characters', () => { + const defaultProps = { + ...getDefaultTestProps(mockResults), + query: 'How was your day?', + }; const wrapper = mountWithIntl( - + ); diff --git a/x-pack/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/plugins/siem/public/components/open_timeline/open_timeline.tsx index 26aeab87e3510c..e172a006abe4b0 100644 --- a/x-pack/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -11,7 +11,6 @@ import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; import { OpenTimelineProps, OpenTimelineResult } from './types'; import { SearchRow } from './search_row'; import { TimelinesTable } from './timelines_table'; -import { TitleRow } from './title_row'; import { ImportDataModal } from '../import_data_modal'; import * as i18n from './translations'; import { importTimelines } from '../../containers/timeline/api'; @@ -52,7 +51,7 @@ export const OpenTimeline = React.memo( sortDirection, setImportDataModalToggle, sortField, - title, + tabs, totalSearchResultsCount, }) => { const tableRef = useRef>(); @@ -98,9 +97,9 @@ export const OpenTimeline = React.memo( const onRefreshBtnClick = useCallback(() => { if (refetch != null) { - refetch(); + refetch(searchResults, totalSearchResultsCount); } - }, [refetch]); + }, [refetch, searchResults, totalSearchResultsCount]); const handleCloseModal = useCallback(() => { if (setImportDataModalToggle != null) { @@ -112,9 +111,9 @@ export const OpenTimeline = React.memo( setImportDataModalToggle(false); } if (refetch != null) { - refetch(); + refetch(searchResults, totalSearchResultsCount); } - }, [setImportDataModalToggle, refetch]); + }, [setImportDataModalToggle, refetch, searchResults, totalSearchResultsCount]); return ( <> @@ -143,21 +142,15 @@ export const OpenTimeline = React.memo( /> - - - + {tabs} + diff --git a/x-pack/plugins/siem/public/components/open_timeline/open_timeline_modal/index.test.tsx b/x-pack/plugins/siem/public/components/open_timeline/open_timeline_modal/index.test.tsx index 46a0d46c1e0d16..178c69e6957e1d 100644 --- a/x-pack/plugins/siem/public/components/open_timeline/open_timeline_modal/index.test.tsx +++ b/x-pack/plugins/siem/public/components/open_timeline/open_timeline_modal/index.test.tsx @@ -28,6 +28,15 @@ jest.mock('../../../containers/timeline/all', () => { getAllTimeline: originalModule.getAllTimeline, }; }); +jest.mock('../use_timeline_types', () => { + return { + useTimelineTypes: jest.fn().mockReturnValue({ + timelineType: 'default', + timelineTabs:
, + timelineFilters:
, + }), + }; +}); describe('OpenTimelineModal', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); diff --git a/x-pack/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx b/x-pack/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx index 2c3adb138b7acf..a610884d287a62 100644 --- a/x-pack/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx +++ b/x-pack/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx @@ -11,7 +11,7 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; -import { OpenTimelineResult } from '../types'; +import { OpenTimelineResult, OpenTimelineProps } from '../types'; import { TimelinesTableProps } from '../timelines_table'; import { mockTimelineResults } from '../../../mock/timeline_results'; import { OpenTimelineModalBody } from './open_timeline_modal_body'; @@ -22,40 +22,43 @@ jest.mock('../../../lib/kibana'); describe('OpenTimelineModal', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); const title = 'All Timelines / Open Timelines'; - let mockResults: OpenTimelineResult[]; + const getDefaultTestProps = (mockSearchResults: OpenTimelineResult[]): OpenTimelineProps => ({ + deleteTimelines: jest.fn(), + defaultPageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, + isLoading: false, + itemIdToExpandedNotesRowMap: {}, + onAddTimelinesToFavorites: jest.fn(), + onDeleteSelected: jest.fn(), + onlyFavorites: false, + onOpenTimeline: jest.fn(), + onQueryChange: jest.fn(), + onSelectionChange: jest.fn(), + onTableChange: jest.fn(), + onToggleOnlyFavorites: jest.fn(), + onToggleShowNotes: jest.fn(), + pageIndex: 0, + pageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, + query: '', + searchResults: mockSearchResults, + selectedItems: [], + sortDirection: DEFAULT_SORT_DIRECTION, + sortField: DEFAULT_SORT_FIELD, + tabs:
, + title, + totalSearchResultsCount: mockSearchResults.length, + }); + beforeEach(() => { mockResults = cloneDeep(mockTimelineResults); }); test('it renders the title row', () => { + const defaultProps = getDefaultTestProps(mockResults); const wrapper = mountWithIntl( - + ); @@ -68,32 +71,10 @@ describe('OpenTimelineModal', () => { }); test('it renders the search row', () => { + const defaultProps = getDefaultTestProps(mockResults); const wrapper = mountWithIntl( - + ); @@ -106,32 +87,10 @@ describe('OpenTimelineModal', () => { }); test('it renders the timelines table', () => { + const defaultProps = getDefaultTestProps(mockResults); const wrapper = mountWithIntl( - + ); @@ -144,32 +103,14 @@ describe('OpenTimelineModal', () => { }); test('it shows the delete action when onDeleteSelected and deleteTimelines are specified', () => { + const defaultProps = { + ...getDefaultTestProps(mockResults), + onDeleteSelected: jest.fn(), + deleteTimelines: jest.fn(), + }; const wrapper = mountWithIntl( - + ); @@ -182,31 +123,14 @@ describe('OpenTimelineModal', () => { }); test('it does NOT show the delete when is onDeleteSelected undefined and deleteTimelines is specified', () => { + const defaultProps = { + ...getDefaultTestProps(mockResults), + onDeleteSelected: undefined, + deleteTimelines: undefined, + }; const wrapper = mountWithIntl( - + ); @@ -219,31 +143,14 @@ describe('OpenTimelineModal', () => { }); test('it does NOT show the delete action when is onDeleteSelected provided and deleteTimelines is undefined', () => { + const defaultProps = { + ...getDefaultTestProps(mockResults), + onDeleteSelected: undefined, + deleteTimelines: undefined, + }; const wrapper = mountWithIntl( - + ); @@ -256,30 +163,14 @@ describe('OpenTimelineModal', () => { }); test('it does NOT show extended columns when both onDeleteSelected and deleteTimelines are undefined', () => { + const defaultProps = { + ...getDefaultTestProps(mockResults), + onDeleteSelected: undefined, + deleteTimelines: undefined, + }; const wrapper = mountWithIntl( - + ); diff --git a/x-pack/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx b/x-pack/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx index 60ebf2118d556f..ebfd01274ab41c 100644 --- a/x-pack/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx +++ b/x-pack/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx @@ -42,6 +42,7 @@ export const OpenTimelineModalBody = memo( selectedItems, sortDirection, sortField, + tabs, title, totalSearchResultsCount, }) => { @@ -52,6 +53,7 @@ export const OpenTimelineModalBody = memo( : ['duplicate']; return actions.filter(action => !hideActions.includes(action)); }, [onDeleteSelected, deleteTimelines, hideActions]); + return ( <> @@ -62,15 +64,17 @@ export const OpenTimelineModalBody = memo( selectedTimelinesCount={selectedItems.length} title={title} /> - - + <> + + diff --git a/x-pack/plugins/siem/public/components/open_timeline/search_row/index.tsx b/x-pack/plugins/siem/public/components/open_timeline/search_row/index.tsx index 55fce1f1c1ed07..b05be6b44418a2 100644 --- a/x-pack/plugins/siem/public/components/open_timeline/search_row/index.tsx +++ b/x-pack/plugins/siem/public/components/open_timeline/search_row/index.tsx @@ -35,7 +35,7 @@ SearchRowFlexGroup.displayName = 'SearchRowFlexGroup'; type Props = Pick< OpenTimelineProps, 'onlyFavorites' | 'onQueryChange' | 'onToggleOnlyFavorites' | 'query' | 'totalSearchResultsCount' ->; +> & { tabs?: JSX.Element }; const searchBox = { placeholder: i18n.SEARCH_PLACEHOLDER, @@ -46,7 +46,14 @@ const searchBox = { * Renders the row containing the search input and Only Favorites filter */ export const SearchRow = React.memo( - ({ onlyFavorites, onQueryChange, onToggleOnlyFavorites, query, totalSearchResultsCount }) => { + ({ + onlyFavorites, + onQueryChange, + onToggleOnlyFavorites, + query, + totalSearchResultsCount, + tabs, + }) => { return ( @@ -55,14 +62,17 @@ export const SearchRow = React.memo( - - - {i18n.ONLY_FAVORITES} - + + <> + + {i18n.ONLY_FAVORITES} + + {tabs} + diff --git a/x-pack/plugins/siem/public/components/open_timeline/translations.ts b/x-pack/plugins/siem/public/components/open_timeline/translations.ts index 7914e368166dbe..80c044c0a1d9f8 100644 --- a/x-pack/plugins/siem/public/components/open_timeline/translations.ts +++ b/x-pack/plugins/siem/public/components/open_timeline/translations.ts @@ -146,6 +146,20 @@ export const SUCCESSFULLY_EXPORTED_TIMELINES = (totalTimelines: number) => 'Successfully exported {totalTimelines, plural, =0 {all timelines} =1 {{totalTimelines} timeline} other {{totalTimelines} timelines}}', }); +export const FILTER_TIMELINES = (timelineType: string) => + i18n.translate('xpack.siem.open.timeline.filterByTimelineTypesTitle', { + values: { timelineType }, + defaultMessage: 'Only {timelineType}', + }); + +export const TAB_TIMELINES = i18n.translate('xpack.siem.timelines.components.tabs.timelinesTitle', { + defaultMessage: 'Timelines', +}); + +export const TAB_TEMPLATES = i18n.translate('xpack.siem.timelines.components.tabs.templatesTitle', { + defaultMessage: 'Templates', +}); + export const IMPORT_TIMELINE_BTN_TITLE = i18n.translate( 'xpack.siem.timelines.components.importTimelineModal.importTimelineTitle', { diff --git a/x-pack/plugins/siem/public/components/open_timeline/types.ts b/x-pack/plugins/siem/public/components/open_timeline/types.ts index 41999c62492776..4d953f6fa775e1 100644 --- a/x-pack/plugins/siem/public/components/open_timeline/types.ts +++ b/x-pack/plugins/siem/public/components/open_timeline/types.ts @@ -8,8 +8,7 @@ import { SetStateAction, Dispatch } from 'react'; import { AllTimelinesVariables } from '../../containers/timeline/all'; import { TimelineModel } from '../../store/timeline/model'; import { NoteResult } from '../../graphql/types'; -import { Refetch } from '../../store/inputs/model'; -import { TimelineType } from '../../../common/types/timeline'; +import { TimelineType, TimelineTypeLiteral } from '../../../common/types/timeline'; /** The users who added a timeline to favorites */ export interface FavoriteTimelineResult { @@ -150,7 +149,7 @@ export interface OpenTimelineProps { /** The currently applied search criteria */ query: string; /** Refetch table */ - refetch?: Refetch; + refetch?: (existingTimeline?: OpenTimelineResult[], existingCount?: number) => void; /** The results of executing a search */ searchResults: OpenTimelineResult[]; /** the currently-selected timelines in the table */ @@ -161,6 +160,8 @@ export interface OpenTimelineProps { sortDirection: 'asc' | 'desc'; /** the requested field to sort on */ sortField: string; + /** timeline / template timeline */ + tabs: JSX.Element; /** The title of the Open Timeline component */ title: string; /** The total (server-side) count of the search results */ @@ -188,3 +189,15 @@ export type DispatchUpdateTimeline = ({ to, ruleNote, }: UpdateTimeline) => () => void; + +export enum TimelineTabsStyle { + tab = 'tab', + filter = 'filter', +} + +export interface TimelineTab { + id: TimelineTypeLiteral; + name: string; + disabled: boolean; + href: string; +} diff --git a/x-pack/plugins/siem/public/components/open_timeline/use_timeline_types.tsx b/x-pack/plugins/siem/public/components/open_timeline/use_timeline_types.tsx new file mode 100644 index 00000000000000..1e23bc5bdda3cb --- /dev/null +++ b/x-pack/plugins/siem/public/components/open_timeline/use_timeline_types.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState, useCallback, useMemo } from 'react'; +import { useParams } from 'react-router-dom'; +import { EuiTabs, EuiTab, EuiSpacer, EuiFilterButton } from '@elastic/eui'; + +import { TimelineTypeLiteralWithNull, TimelineType } from '../../../common/types/timeline'; + +import { getTimelineTabsUrl } from '../link_to'; +import { useGetUrlSearch } from '../navigation/use_get_url_search'; +import { navTabs } from '../../pages/home/home_navigations'; + +import * as i18n from './translations'; +import { TimelineTabsStyle, TimelineTab } from './types'; + +export const useTimelineTypes = (): { + timelineType: TimelineTypeLiteralWithNull; + timelineTabs: JSX.Element; + timelineFilters: JSX.Element; +} => { + const urlSearch = useGetUrlSearch(navTabs.timelines); + const { tabName } = useParams<{ pageName: string; tabName: string }>(); + const [timelineType, setTimelineTypes] = useState( + tabName === TimelineType.default || tabName === TimelineType.template ? tabName : null + ); + + const getFilterOrTabs: (timelineTabsStyle: TimelineTabsStyle) => TimelineTab[] = ( + timelineTabsStyle: TimelineTabsStyle + ) => [ + { + id: TimelineType.default, + name: + timelineTabsStyle === TimelineTabsStyle.filter + ? i18n.FILTER_TIMELINES(i18n.TAB_TIMELINES) + : i18n.TAB_TIMELINES, + href: getTimelineTabsUrl(TimelineType.default, urlSearch), + disabled: false, + }, + { + id: TimelineType.template, + name: + timelineTabsStyle === TimelineTabsStyle.filter + ? i18n.FILTER_TIMELINES(i18n.TAB_TEMPLATES) + : i18n.TAB_TEMPLATES, + href: getTimelineTabsUrl(TimelineType.template, urlSearch), + disabled: false, + }, + ]; + + const onFilterClicked = useCallback( + (timelineTabsStyle, tabId) => { + if (timelineTabsStyle === TimelineTabsStyle.filter && tabId === timelineType) { + setTimelineTypes(null); + } else { + setTimelineTypes(tabId); + } + }, + [timelineType, setTimelineTypes] + ); + + const timelineTabs = useMemo(() => { + return ( + <> + + {getFilterOrTabs(TimelineTabsStyle.tab).map((tab: TimelineTab) => ( + + {tab.name} + + ))} + + + + ); + }, [tabName]); + + const timelineFilters = useMemo(() => { + return ( + <> + {getFilterOrTabs(TimelineTabsStyle.tab).map((tab: TimelineTab) => ( + + {tab.name} + + ))} + + ); + }, [timelineType]); + + return { + timelineType, + timelineTabs, + timelineFilters, + }; +}; diff --git a/x-pack/plugins/siem/public/components/recent_timelines/index.tsx b/x-pack/plugins/siem/public/components/recent_timelines/index.tsx index b641038f35ba6d..d3532d9fd1025f 100644 --- a/x-pack/plugins/siem/public/components/recent_timelines/index.tsx +++ b/x-pack/plugins/siem/public/components/recent_timelines/index.tsx @@ -10,6 +10,8 @@ import React, { useCallback, useMemo, useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { Dispatch } from 'redux'; +import { TimelineType } from '../../../common/types/timeline'; + import { useGetAllTimeline } from '../../containers/timeline/all'; import { SortFieldTimeline, Direction } from '../../graphql/types'; import { queryTimelineById, dispatchUpdateTimeline } from '../open_timeline/helpers'; @@ -62,7 +64,7 @@ const StatefulRecentTimelinesComponent = React.memo( [filterBy] ); - const { fetchAllTimeline, timelines, totalCount, loading } = useGetAllTimeline(); + const { fetchAllTimeline, timelines, loading } = useGetAllTimeline(); useEffect(() => { fetchAllTimeline({ @@ -76,10 +78,9 @@ const StatefulRecentTimelinesComponent = React.memo( sortOrder: Direction.desc, }, onlyUserFavorite: filterBy === 'favorites', - timelines, - totalCount, + timelineType: TimelineType.default, }); - }, [filterBy, timelines, totalCount]); + }, [filterBy]); return ( <> diff --git a/x-pack/plugins/siem/public/components/timeline/selectable_timeline/index.tsx b/x-pack/plugins/siem/public/components/timeline/selectable_timeline/index.tsx index 4cc89e5bdba73d..964bb2061333d4 100644 --- a/x-pack/plugins/siem/public/components/timeline/selectable_timeline/index.tsx +++ b/x-pack/plugins/siem/public/components/timeline/selectable_timeline/index.tsx @@ -23,6 +23,8 @@ import styled from 'styled-components'; import { useGetAllTimeline } from '../../../containers/timeline/all'; import { SortFieldTimeline, Direction } from '../../../graphql/types'; +import { TimelineType, TimelineTypeLiteralWithNull } from '../../../../common/types/timeline'; + import { isUntitled } from '../../open_timeline/helpers'; import * as i18nTimeline from '../../open_timeline/translations'; import { OpenTimelineResult } from '../../open_timeline/types'; @@ -71,6 +73,7 @@ const TIMELINE_ITEM_HEIGHT = 50; export interface GetSelectableOptions { timelines: OpenTimelineResult[]; onlyFavorites: boolean; + timelineType?: TimelineTypeLiteralWithNull; searchTimelineValue: string; } @@ -79,6 +82,7 @@ interface SelectableTimelineProps { getSelectableOptions: ({ timelines, onlyFavorites, + timelineType, searchTimelineValue, }: GetSelectableOptions) => EuiSelectableOption[]; onClosePopover: () => void; @@ -228,10 +232,9 @@ const SelectableTimelineComponent: React.FC = ({ sortOrder: Direction.desc, }, onlyUserFavorite: onlyFavorites, - timelines, - totalCount: timelineCount, + timelineType: TimelineType.default, }); - }, [onlyFavorites, pageSize, searchTimelineValue, timelines, timelineCount]); + }, [onlyFavorites, pageSize, searchTimelineValue]); return ( @@ -263,7 +266,12 @@ const SelectableTimelineComponent: React.FC = ({ }, }} singleSelection={true} - options={getSelectableOptions({ timelines, onlyFavorites, searchTimelineValue })} + options={getSelectableOptions({ + timelines, + onlyFavorites, + searchTimelineValue, + timelineType: TimelineType.default, + })} > {(list, search) => ( <> diff --git a/x-pack/plugins/siem/public/containers/timeline/all/index.gql_query.ts b/x-pack/plugins/siem/public/containers/timeline/all/index.gql_query.ts index 7d30b6c22a1100..76aef8de4ad84c 100644 --- a/x-pack/plugins/siem/public/containers/timeline/all/index.gql_query.ts +++ b/x-pack/plugins/siem/public/containers/timeline/all/index.gql_query.ts @@ -12,12 +12,14 @@ export const allTimelinesQuery = gql` $search: String $sort: SortTimeline $onlyUserFavorite: Boolean + $timelineType: String ) { getAllTimeline( pageInfo: $pageInfo search: $search sort: $sort onlyUserFavorite: $onlyUserFavorite + timelineType: $timelineType ) { totalCount timeline { diff --git a/x-pack/plugins/siem/public/containers/timeline/all/index.tsx b/x-pack/plugins/siem/public/containers/timeline/all/index.tsx index 62c8d21a2e9448..e1d1edc1a8cecb 100644 --- a/x-pack/plugins/siem/public/containers/timeline/all/index.tsx +++ b/x-pack/plugins/siem/public/containers/timeline/all/index.tsx @@ -6,7 +6,7 @@ import { getOr, noop } from 'lodash/fp'; import memoizeOne from 'memoize-one'; -import { useCallback, useState, useRef, useEffect } from 'react'; +import { useCallback, useState, useEffect } from 'react'; import { useDispatch } from 'react-redux'; import { OpenTimelineResult } from '../../../components/open_timeline/types'; @@ -17,18 +17,24 @@ import { SortTimeline, TimelineResult, } from '../../../graphql/types'; -import { inputsModel, inputsActions } from '../../../store/inputs'; +import { inputsActions } from '../../../store/inputs'; import { useApolloClient } from '../../../utils/apollo_context'; import { allTimelinesQuery } from './index.gql_query'; import * as i18n from '../../../pages/timelines/translations'; +import { TimelineTypeLiteralWithNull } from '../../../../common/types/timeline'; export interface AllTimelinesArgs { - fetchAllTimeline: ({ onlyUserFavorite, pageInfo, search, sort }: AllTimelinesVariables) => void; + fetchAllTimeline: ({ + onlyUserFavorite, + pageInfo, + search, + sort, + timelineType, + }: AllTimelinesVariables) => void; timelines: OpenTimelineResult[]; loading: boolean; totalCount: number; - refetch: () => void; } export interface AllTimelinesVariables { @@ -36,8 +42,7 @@ export interface AllTimelinesVariables { pageInfo: PageInfoTimeline; search: string; sort: SortTimeline; - timelines: OpenTimelineResult[]; - totalCount: number; + timelineType: TimelineTypeLiteralWithNull; } export const ALL_TIMELINE_QUERY_ID = 'FETCH_ALL_TIMELINES'; @@ -80,25 +85,16 @@ export const getAllTimeline = memoizeOne( export const useGetAllTimeline = (): AllTimelinesArgs => { const dispatch = useDispatch(); const apolloClient = useApolloClient(); - const refetch = useRef(); const [, dispatchToaster] = useStateToaster(); const [allTimelines, setAllTimelines] = useState({ fetchAllTimeline: noop, loading: false, - refetch: refetch.current ?? noop, totalCount: 0, timelines: [], }); const fetchAllTimeline = useCallback( - async ({ - onlyUserFavorite, - pageInfo, - search, - sort, - timelines, - totalCount, - }: AllTimelinesVariables) => { + async ({ onlyUserFavorite, pageInfo, search, sort, timelineType }: AllTimelinesVariables) => { let didCancel = false; const abortCtrl = new AbortController(); @@ -107,15 +103,15 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { if (apolloClient != null) { setAllTimelines({ ...allTimelines, - timelines: timelines ?? allTimelines.timelines, - totalCount: totalCount ?? allTimelines.totalCount, loading: true, }); + const variables: GetAllTimeline.Variables = { onlyUserFavorite, pageInfo, search, sort, + timelineType, }; const response = await apolloClient.query< GetAllTimeline.Query, @@ -130,25 +126,23 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { }, }, }); + const totalCount = response?.data?.getAllTimeline?.totalCount ?? 0; + const timelines = response?.data?.getAllTimeline?.timeline ?? []; if (!didCancel) { dispatch( inputsActions.setQuery({ inputId: 'global', id: ALL_TIMELINE_QUERY_ID, loading: false, - refetch: refetch.current ?? noop, + refetch: fetchData, inspect: null, }) ); setAllTimelines({ fetchAllTimeline, loading: false, - refetch: refetch.current ?? noop, - totalCount: getOr(0, 'getAllTimeline.totalCount', response.data), - timelines: getAllTimeline( - JSON.stringify(variables), - getOr([], 'getAllTimeline.timeline', response.data) - ), + totalCount, + timelines: getAllTimeline(JSON.stringify(variables), timelines as TimelineResult[]), }); } } @@ -162,14 +156,12 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { setAllTimelines({ fetchAllTimeline, loading: false, - refetch: noop, totalCount: 0, timelines: [], }); } } }; - refetch.current = fetchData; fetchData(); return () => { didCancel = true; @@ -188,6 +180,5 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { return { ...allTimelines, fetchAllTimeline, - refetch: refetch.current ?? noop, }; }; diff --git a/x-pack/plugins/siem/public/graphql/introspection.json b/x-pack/plugins/siem/public/graphql/introspection.json index 4026a043c7778c..c2b21957a90565 100644 --- a/x-pack/plugins/siem/public/graphql/introspection.json +++ b/x-pack/plugins/siem/public/graphql/introspection.json @@ -249,6 +249,12 @@ "description": "", "type": { "kind": "SCALAR", "name": "Boolean", "ofType": null }, "defaultValue": null + }, + { + "name": "timelineType", + "description": "", + "type": { "kind": "SCALAR", "name": "String", "ofType": null }, + "defaultValue": null } ], "type": { diff --git a/x-pack/plugins/siem/public/graphql/types.ts b/x-pack/plugins/siem/public/graphql/types.ts index 86890988c06b64..dd4e967b185b9b 100644 --- a/x-pack/plugins/siem/public/graphql/types.ts +++ b/x-pack/plugins/siem/public/graphql/types.ts @@ -2235,6 +2235,8 @@ export interface GetAllTimelineQueryArgs { sort?: Maybe; onlyUserFavorite?: Maybe; + + timelineType?: Maybe; } export interface AuthenticationsSourceArgs { timerange: TimerangeInput; @@ -4012,6 +4014,7 @@ export namespace GetAllTimeline { search?: Maybe; sort?: Maybe; onlyUserFavorite?: Maybe; + timelineType?: Maybe; }; export type Query = { diff --git a/x-pack/plugins/siem/public/pages/timelines/index.tsx b/x-pack/plugins/siem/public/pages/timelines/index.tsx index aa5c891de36285..343be5cbe3839e 100644 --- a/x-pack/plugins/siem/public/pages/timelines/index.tsx +++ b/x-pack/plugins/siem/public/pages/timelines/index.tsx @@ -6,11 +6,66 @@ import React from 'react'; import { ApolloConsumer } from 'react-apollo'; +import { Switch, Route, Redirect } from 'react-router-dom'; + +import { ChromeBreadcrumb } from '../../../../../../src/core/public'; + +import { TimelineType } from '../../../common/types/timeline'; +import { TAB_TIMELINES, TAB_TEMPLATES } from '../../components/open_timeline/translations'; +import { getTimelinesUrl } from '../../components/link_to'; +import { TimelineRouteSpyState } from '../../utils/route/types'; + +import { SiemPageName } from '../home/types'; import { TimelinesPage } from './timelines_page'; +import { PAGE_TITLE } from './translations'; +import { appendSearch } from '../../components/link_to/helpers'; +const timelinesPagePath = `/:pageName(${SiemPageName.timelines})/:tabName(${TimelineType.default}|${TimelineType.template})`; +const timelinesDefaultPath = `/${SiemPageName.timelines}/${TimelineType.default}`; + +const TabNameMappedToI18nKey: Record = { + [TimelineType.default]: TAB_TIMELINES, + [TimelineType.template]: TAB_TEMPLATES, +}; + +export const getBreadcrumbs = ( + params: TimelineRouteSpyState, + search: string[] +): ChromeBreadcrumb[] => { + let breadcrumb = [ + { + text: PAGE_TITLE, + href: `${getTimelinesUrl(appendSearch(search[1]))}`, + }, + ]; + + const tabName = params?.tabName; + if (!tabName) return breadcrumb; + + breadcrumb = [ + ...breadcrumb, + { + text: TabNameMappedToI18nKey[tabName], + href: '', + }, + ]; + return breadcrumb; +}; -export const Timelines = React.memo(() => ( - {client => } -)); +export const Timelines = React.memo(() => { + return ( + + + {client => } + + ( + + )} + /> + + ); +}); Timelines.displayName = 'Timelines'; diff --git a/x-pack/plugins/siem/public/store/timeline/model.ts b/x-pack/plugins/siem/public/store/timeline/model.ts index 7885064380eff4..54e19812634ac2 100644 --- a/x-pack/plugins/siem/public/store/timeline/model.ts +++ b/x-pack/plugins/siem/public/store/timeline/model.ts @@ -80,7 +80,7 @@ export interface TimelineModel { }; /** Title */ title: string; - /** timelineTypes: default | template */ + /** timelineType: default | template */ timelineType: TimelineTypeLiteralWithNull; /** an unique id for template timeline */ templateTimelineId: string | null; diff --git a/x-pack/plugins/siem/public/utils/route/types.ts b/x-pack/plugins/siem/public/utils/route/types.ts index d3eca36bd0d96d..17b312a427c435 100644 --- a/x-pack/plugins/siem/public/utils/route/types.ts +++ b/x-pack/plugins/siem/public/utils/route/types.ts @@ -8,11 +8,13 @@ import * as H from 'history'; import React from 'react'; import { RouteComponentProps } from 'react-router-dom'; +import { TimelineType } from '../../../common/types/timeline'; + import { HostsTableType } from '../../store/hosts/model'; import { NetworkRouteType } from '../../pages/network/navigation/types'; import { FlowTarget } from '../../graphql/types'; -export type SiemRouteType = HostsTableType | NetworkRouteType; +export type SiemRouteType = HostsTableType | NetworkRouteType | TimelineType; export interface RouteSpyState { pageName: string; detailName: string | undefined; @@ -32,6 +34,10 @@ export interface NetworkRouteSpyState extends RouteSpyState { tabName: NetworkRouteType | undefined; } +export interface TimelineRouteSpyState extends RouteSpyState { + tabName: TimelineType | undefined; +} + export type RouteSpyAction = | { type: 'updateSearch'; diff --git a/x-pack/plugins/siem/server/graphql/timeline/resolvers.ts b/x-pack/plugins/siem/server/graphql/timeline/resolvers.ts index a33751179e93a8..a40ef5466c7808 100644 --- a/x-pack/plugins/siem/server/graphql/timeline/resolvers.ts +++ b/x-pack/plugins/siem/server/graphql/timeline/resolvers.ts @@ -52,7 +52,8 @@ export const createTimelineResolvers = ( args.onlyUserFavorite || null, args.pageInfo || null, args.search || null, - args.sort || null + args.sort || null, + args.timelineType || null ); }, }, diff --git a/x-pack/plugins/siem/server/graphql/timeline/schema.gql.ts b/x-pack/plugins/siem/server/graphql/timeline/schema.gql.ts index bc2b3a53d85f3e..a1c13fd21a88e9 100644 --- a/x-pack/plugins/siem/server/graphql/timeline/schema.gql.ts +++ b/x-pack/plugins/siem/server/graphql/timeline/schema.gql.ts @@ -278,7 +278,7 @@ export const timelineSchema = gql` extend type Query { getOneTimeline(id: ID!): TimelineResult! - getAllTimeline(pageInfo: PageInfoTimeline, search: String, sort: SortTimeline, onlyUserFavorite: Boolean): ResponseTimelines! + getAllTimeline(pageInfo: PageInfoTimeline, search: String, sort: SortTimeline, onlyUserFavorite: Boolean, timelineType: String): ResponseTimelines! } extend type Mutation { diff --git a/x-pack/plugins/siem/server/graphql/types.ts b/x-pack/plugins/siem/server/graphql/types.ts index 6a35ba08f8e43f..d74086357edbeb 100644 --- a/x-pack/plugins/siem/server/graphql/types.ts +++ b/x-pack/plugins/siem/server/graphql/types.ts @@ -2237,6 +2237,8 @@ export interface GetAllTimelineQueryArgs { sort?: Maybe; onlyUserFavorite?: Maybe; + + timelineType?: Maybe; } export interface AuthenticationsSourceArgs { timerange: TimerangeInput; @@ -2693,6 +2695,8 @@ export namespace QueryResolvers { sort?: Maybe; onlyUserFavorite?: Maybe; + + timelineType?: Maybe; } } diff --git a/x-pack/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts b/x-pack/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts index 99621f1391acb9..4a79dada071711 100644 --- a/x-pack/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts +++ b/x-pack/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts @@ -119,35 +119,35 @@ export const importTimelinesRoute = ( return null; } const { - savedObjectId, + savedObjectId = null, pinnedEventIds, globalNotes, eventNotes, templateTimelineId, templateTimelineVersion, timelineType, + version = null, } = parsedTimeline; const parsedTimelineObject = omit( timelineSavedObjectOmittedFields, parsedTimeline ); + let newTimeline = null; try { const templateTimeline = templateTimelineId != null ? await getTemplateTimeline(frameworkRequest, templateTimelineId) : null; + const timeline = - templateTimeline?.savedObjectId != null || savedObjectId != null - ? await getTimeline( - frameworkRequest, - templateTimeline?.savedObjectId ?? savedObjectId - ) - : null; + savedObjectId != null && + (await getTimeline(frameworkRequest, savedObjectId)); const isHandlingTemplateTimeline = timelineType === TimelineType.template; + if ( (timeline == null && !isHandlingTemplateTimeline) || - (templateTimeline == null && isHandlingTemplateTimeline) + (timeline == null && templateTimeline == null && isHandlingTemplateTimeline) ) { // create timeline / template timeline newTimeline = await createTimelines( @@ -156,7 +156,9 @@ export const importTimelinesRoute = ( null, // timelineSavedObjectId null, // timelineVersion pinnedEventIds, - [...globalNotes, ...eventNotes], + isHandlingTemplateTimeline + ? globalNotes + : [...globalNotes, ...eventNotes], [] // existing note ids ); @@ -165,6 +167,7 @@ export const importTimelinesRoute = ( status_code: 200, }); } else if ( + timeline && timeline != null && templateTimeline != null && isHandlingTemplateTimeline @@ -172,8 +175,8 @@ export const importTimelinesRoute = ( // update template timeline const errorObj = checkIsFailureCases( isHandlingTemplateTimeline, - timeline.version, - templateTimeline.templateTimelineVersion ?? null, + version, + templateTimelineVersion ?? null, timeline, templateTimeline ); @@ -198,16 +201,16 @@ export const importTimelinesRoute = ( } else { resolve( createBulkErrorObject({ - id: savedObjectId, + id: savedObjectId ?? 'unknown', statusCode: 409, - message: `timeline_id: "${timeline?.savedObjectId}" already exists`, + message: `timeline_id: "${savedObjectId}" already exists`, }) ); } } catch (err) { resolve( createBulkErrorObject({ - id: savedObjectId, + id: savedObjectId ?? 'unknown', statusCode: 400, message: err.message, }) diff --git a/x-pack/plugins/siem/server/lib/timeline/routes/utils/import_timelines.ts b/x-pack/plugins/siem/server/lib/timeline/routes/utils/import_timelines.ts index a49627d40c8f57..3f46b9ba91dc47 100644 --- a/x-pack/plugins/siem/server/lib/timeline/routes/utils/import_timelines.ts +++ b/x-pack/plugins/siem/server/lib/timeline/routes/utils/import_timelines.ts @@ -18,7 +18,8 @@ export interface ImportTimelinesSchema { } export type ImportedTimeline = SavedTimeline & { - savedObjectId: string; + savedObjectId: string | null; + version: string | null; pinnedEventIds: string[]; globalNotes: NoteResult[]; eventNotes: NoteResult[]; @@ -86,16 +87,18 @@ export const isBulkError = ( return has('error', importRuleResponse); }; +/** + * This fields do not exists in savedObject mapping, but exist in Users' import, + * exclude them here to avoid creating savedObject failure + */ export const timelineSavedObjectOmittedFields = [ 'globalNotes', 'eventNotes', 'pinnedEventIds', - 'version', 'savedObjectId', 'created', 'createdBy', 'updated', 'updatedBy', - 'templateTimelineId', - 'templateTimelineVersion', + 'version', ]; diff --git a/x-pack/plugins/siem/server/lib/timeline/saved_object.ts b/x-pack/plugins/siem/server/lib/timeline/saved_object.ts index d2df7589f3c4a9..6d022ab42fa7b1 100644 --- a/x-pack/plugins/siem/server/lib/timeline/saved_object.ts +++ b/x-pack/plugins/siem/server/lib/timeline/saved_object.ts @@ -47,7 +47,8 @@ export interface Timeline { onlyUserFavorite: boolean | null, pageInfo: PageInfoTimeline | null, search: string | null, - sort: SortTimeline | null + sort: SortTimeline | null, + timelineType: string | null ) => Promise; persistFavorite: ( @@ -94,12 +95,24 @@ export const getTimelineByTemplateTimelineId = async ( return getAllSavedTimeline(request, options); }; +/** The filter here is able to handle the legacy data, + * which has no timelineType exists in the savedObject */ +const getTimelineTypeFilter = (timelineType: string | null) => { + return timelineType === TimelineType.template + ? `siem-ui-timeline.attributes.timelineType: ${TimelineType.template}` /** Show only whose timelineType exists and equals to "template" */ + : /** Show me every timeline whose timelineType is not "template". + * which includes timelineType === 'default' and + * those timelineType doesn't exists */ + `not siem-ui-timeline.attributes.timelineType: ${TimelineType.template}`; +}; + export const getAllTimeline = async ( request: FrameworkRequest, onlyUserFavorite: boolean | null, pageInfo: PageInfoTimeline | null, search: string | null, - sort: SortTimeline | null + sort: SortTimeline | null, + timelineType: string | null ): Promise => { const options: SavedObjectsFindOptions = { type: timelineSavedObjectType, @@ -109,6 +122,7 @@ export const getAllTimeline = async ( searchFields: onlyUserFavorite ? ['title', 'description', 'favorite.keySearch'] : ['title', 'description'], + filter: getTimelineTypeFilter(timelineType), sortField: sort != null ? sort.sortField : undefined, sortOrder: sort != null ? sort.sortOrder : undefined, };