diff --git a/x-pack/plugins/security_solution/common/api/timeline/delete_timelines/delete_timelines_route.ts b/x-pack/plugins/security_solution/common/api/timeline/delete_timelines/delete_timelines_route.ts index 4aadb73283676..c6b8f1baf6974 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/delete_timelines/delete_timelines_route.ts +++ b/x-pack/plugins/security_solution/common/api/timeline/delete_timelines/delete_timelines_route.ts @@ -7,6 +7,10 @@ import * as rt from 'io-ts'; -export const deleteTimelinesSchema = rt.type({ +const searchId = rt.partial({ searchIds: rt.array(rt.string) }); + +const baseDeleteTimelinesSchema = rt.type({ savedObjectIds: rt.array(rt.string), }); + +export const deleteTimelinesSchema = rt.intersection([baseDeleteTimelinesSchema, searchId]); diff --git a/x-pack/plugins/security_solution/common/api/timeline/delete_timelines/delete_timelines_route_schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/delete_timelines/delete_timelines_route_schema.yaml index e6c262f70626e..dba0471992729 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/delete_timelines/delete_timelines_route_schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/delete_timelines/delete_timelines_route_schema.yaml @@ -33,6 +33,10 @@ paths: type: array items: type: string + searchId: + type: array + items: + type: string responses: 200: description: Indicates the timeline was successfully deleted. diff --git a/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.tsx b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.tsx index 6cfc5729b726a..47c1d8b478c2d 100644 --- a/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.tsx +++ b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.tsx @@ -48,7 +48,7 @@ export const useDiscoverInTimelineActions = ( const timeline = useShallowEqualSelector( (state) => getTimeline(state, TimelineId.active) ?? timelineDefaults ); - const { savedSearchId } = timeline; + const { savedSearchId, version } = timeline; // We're using a ref here to prevent a cyclic hook-dependency chain of updateSavedSearch const timelineRef = useRef(timeline); @@ -56,7 +56,7 @@ export const useDiscoverInTimelineActions = ( const queryClient = useQueryClient(); - const { mutateAsync: saveSavedSearch } = useMutation({ + const { mutateAsync: saveSavedSearch, status } = useMutation({ mutationFn: ({ savedSearch, savedSearchOptions, @@ -75,6 +75,7 @@ export const useDiscoverInTimelineActions = ( } queryClient.invalidateQueries({ queryKey: ['savedSearchById', savedSearchId] }); }, + mutationKey: [version], }); const getDefaultDiscoverAppState: () => Promise = useCallback(async () => { @@ -217,7 +218,7 @@ export const useDiscoverInTimelineActions = ( const responseIsEmpty = !response || !response?.id; if (responseIsEmpty) { throw new Error('Response is empty'); - } else if (!savedSearchId && !responseIsEmpty) { + } else if (!savedSearchId && !responseIsEmpty && status !== 'loading') { dispatch( timelineActions.updateSavedSearchId({ id: TimelineId.active, @@ -236,7 +237,7 @@ export const useDiscoverInTimelineActions = ( } } }, - [persistSavedSearch, savedSearchId, dispatch, discoverDataService] + [persistSavedSearch, savedSearchId, dispatch, discoverDataService, status] ); const initializeLocalSavedSearch = useCallback( diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.tsx index 54592e6a494cf..b76565989fb74 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.tsx @@ -27,13 +27,14 @@ interface Props { onComplete?: () => void; isModalOpen: boolean; savedObjectIds: string[]; + savedSearchIds?: string[]; title: string | null; } /** * Renders a button that when clicked, displays the `Delete Timeline` modal */ export const DeleteTimelineModalOverlay = React.memo( - ({ deleteTimelines, isModalOpen, savedObjectIds, title, onComplete }) => { + ({ deleteTimelines, isModalOpen, savedObjectIds, title, onComplete, savedSearchIds }) => { const { addSuccess } = useAppToasts(); const { tabName: timelineType } = useParams<{ tabName: TimelineType }>(); @@ -43,9 +44,16 @@ export const DeleteTimelineModalOverlay = React.memo( } }, [onComplete]); const onDelete = useCallback(() => { - if (savedObjectIds.length > 0) { + if (savedObjectIds.length > 0 && savedSearchIds != null && savedSearchIds.length > 0) { + deleteTimelines(savedObjectIds, savedSearchIds); + addSuccess({ + title: + timelineType === TimelineType.template + ? i18n.SUCCESSFULLY_DELETED_TIMELINE_TEMPLATES(savedObjectIds.length) + : i18n.SUCCESSFULLY_DELETED_TIMELINES(savedObjectIds.length), + }); + } else if (savedObjectIds.length > 0) { deleteTimelines(savedObjectIds); - addSuccess({ title: timelineType === TimelineType.template @@ -53,10 +61,11 @@ export const DeleteTimelineModalOverlay = React.memo( : i18n.SUCCESSFULLY_DELETED_TIMELINES(savedObjectIds.length), }); } + if (onComplete != null) { onComplete(); } - }, [deleteTimelines, savedObjectIds, onComplete, addSuccess, timelineType]); + }, [deleteTimelines, savedObjectIds, onComplete, addSuccess, timelineType, savedSearchIds]); return ( <> {isModalOpen && } diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx index 7504e38db6ddb..67d0c5a9e4599 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx @@ -15,14 +15,7 @@ import * as i18n from './translations'; import type { DeleteTimelines, OpenTimelineResult } from './types'; import { EditTimelineActions } from './export_timeline'; import { useEditTimelineActions } from './edit_timeline_actions'; - -const getExportedIds = (selectedTimelines: OpenTimelineResult[]) => { - const array = Array.isArray(selectedTimelines) ? selectedTimelines : [selectedTimelines]; - return array.reduce( - (acc, item) => (item.savedObjectId != null ? [...acc, item.savedObjectId] : [...acc]), - [] as string[] - ); -}; +import { getSelectedTimelineIdsAndSearchIds, getRequestIds } from '.'; export const useEditTimelineBatchActions = ({ deleteTimelines, @@ -56,7 +49,13 @@ export const useEditTimelineBatchActions = ({ [disableExportTimelineDownloader, onCloseDeleteTimelineModal, tableRef] ); - const selectedIds = useMemo(() => getExportedIds(selectedItems ?? []), [selectedItems]); + const { timelineIds, searchIds } = useMemo(() => { + if (selectedItems != null) { + return getRequestIds(getSelectedTimelineIdsAndSearchIds(selectedItems)); + } else { + return { timelineIds: [], searchIds: undefined }; + } + }, [selectedItems]); const handleEnableExportTimelineDownloader = useCallback( () => enableExportTimelineDownloader(), @@ -102,7 +101,8 @@ export const useEditTimelineBatchActions = ({ <> void; @@ -27,6 +28,7 @@ export const EditTimelineActionsComponent: React.FC<{ }> = ({ deleteTimelines, ids, + savedSearchIds, isEnableDownloader, isDeleteTimelineModalOpen, onComplete, @@ -46,6 +48,7 @@ export const EditTimelineActionsComponent: React.FC<{ isModalOpen={isDeleteTimelineModalOpen} onComplete={onComplete} savedObjectIds={ids} + savedSearchIds={savedSearchIds} title={title} /> )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx index dc2cca5104497..a7751cfb02d2e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx @@ -74,14 +74,51 @@ export type OpenTimelineOwnProps = OwnProps & >; /** Returns a collection of selected timeline ids */ -export const getSelectedTimelineIds = (selectedItems: OpenTimelineResult[]): string[] => - selectedItems.reduce( - (validSelections, timelineResult) => - timelineResult.savedObjectId != null - ? [...validSelections, timelineResult.savedObjectId] - : validSelections, - [] +export const getSelectedTimelineIdsAndSearchIds = ( + selectedItems: OpenTimelineResult[] +): Array<{ timelineId: string; searchId?: string | null }> => { + return selectedItems.reduce>( + (validSelections, timelineResult) => { + if (timelineResult.savedObjectId != null && timelineResult.savedSearchId != null) { + return [ + ...validSelections, + { timelineId: timelineResult.savedObjectId, searchId: timelineResult.savedSearchId }, + ]; + } else if (timelineResult.savedObjectId != null) { + return [...validSelections, { timelineId: timelineResult.savedObjectId }]; + } else { + return validSelections; + } + }, + [] as Array<{ timelineId: string; searchId?: string | null }> + ); +}; + +interface DeleteTimelinesValues { + timelineIds: string[]; + searchIds: string[]; +} + +export const getRequestIds = ( + timelineIdsWithSearch: Array<{ timelineId: string; searchId?: string | null }> +) => { + return timelineIdsWithSearch.reduce( + (acc, { timelineId, searchId }) => { + let requestValues = acc; + if (searchId != null) { + requestValues = { ...requestValues, searchIds: [...requestValues.searchIds, searchId] }; + } + if (timelineId != null) { + requestValues = { + ...requestValues, + timelineIds: [...requestValues.timelineIds, timelineId], + }; + } + return requestValues; + }, + { timelineIds: [], searchIds: [] } ); +}; /** Manages the state (e.g table selection) of the (pure) `OpenTimeline` component */ // eslint-disable-next-line react/display-name @@ -208,7 +245,7 @@ export const StatefulOpenTimelineComponent = React.memo( // }; const deleteTimelines: DeleteTimelines = useCallback( - async (timelineIds: string[]) => { + async (timelineIds: string[], searchIds?: string[]) => { startTransaction({ name: timelineIds.length > 1 ? TIMELINE_ACTIONS.BULK_DELETE : TIMELINE_ACTIONS.DELETE, }); @@ -225,16 +262,16 @@ export const StatefulOpenTimelineComponent = React.memo( ); } - await deleteTimelinesByIds(timelineIds); + await deleteTimelinesByIds(timelineIds, searchIds); refetch(); }, [startTransaction, timelineSavedObjectId, refetch, dispatch, dataViewId, selectedPatterns] ); const onDeleteOneTimeline: OnDeleteOneTimeline = useCallback( - async (timelineIds: string[]) => { + async (timelineIds: string[], searchIds?: string[]) => { // The type for `deleteTimelines` is incorrect, it returns a Promise - await deleteTimelines(timelineIds); + await deleteTimelines(timelineIds, searchIds); }, [deleteTimelines] ); @@ -242,7 +279,9 @@ export const StatefulOpenTimelineComponent = React.memo( /** Invoked when the user clicks the action to delete the selected timelines */ const onDeleteSelected: OnDeleteSelected = useCallback(async () => { // The type for `deleteTimelines` is incorrect, it returns a Promise - await deleteTimelines(getSelectedTimelineIds(selectedItems)); + const timelineIdsWithSearch = getSelectedTimelineIdsAndSearchIds(selectedItems); + const { timelineIds, searchIds } = getRequestIds(timelineIdsWithSearch); + await deleteTimelines(timelineIds, searchIds); // NOTE: we clear the selection state below, but if the server fails to // delete a timeline, it will remain selected in the table: diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx index de993c8aa4ff9..d1392a65192f8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx @@ -129,6 +129,12 @@ export const OpenTimeline = React.memo( [actionItem] ); + const actionItemSavedSearchId = useMemo(() => { + return actionItem != null && actionItem.savedSearchId != null + ? [actionItem.savedSearchId] + : undefined; + }, [actionItem]); + const onRefreshBtnClick = useCallback(() => { if (refetch != null) { refetch(); @@ -197,6 +203,7 @@ export const OpenTimeline = React.memo( > | null; queryType?: { hasEql: boolean; hasQuery: boolean }; savedObjectId?: string | null; + savedSearchId?: string | null; status?: TimelineStatus | null; title?: string | null; templateTimelineId?: string | null; @@ -77,7 +77,7 @@ export interface EuiSearchBarQuery { } /** Performs IO to delete the specified timelines */ -export type DeleteTimelines = (timelineIds: string[], variables?: AllTimelinesVariables) => void; +export type DeleteTimelines = (timelineIds: string[], searchIds?: string[]) => void; /** Invoked when the user clicks the action create rule from timeline */ export type OnCreateRuleFromTimeline = (savedObjectId: string) => void; diff --git a/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx index 72e85c77b0dbf..5e88cf8b63cfe 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx @@ -88,6 +88,7 @@ export const getAllTimeline = memoizeOne( ) : null, savedObjectId: timeline.savedObjectId, + savedSearchId: timeline.savedSearchId, status: timeline.status, title: timeline.title, updated: timeline.updated, diff --git a/x-pack/plugins/security_solution/public/timelines/containers/api.ts b/x-pack/plugins/security_solution/public/timelines/containers/api.ts index f39143bbfa767..4b1c106230fdd 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/api.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/api.ts @@ -480,13 +480,20 @@ export const persistFavorite = async ({ return decodeResponseFavoriteTimeline(response); }; -export const deleteTimelinesByIds = async (savedObjectIds: string[]) => { +export const deleteTimelinesByIds = async (savedObjectIds: string[], searchIds?: string[]) => { let requestBody; try { - requestBody = JSON.stringify({ - savedObjectIds, - }); + if (searchIds) { + requestBody = JSON.stringify({ + savedObjectIds, + searchIds, + }); + } else { + requestBody = JSON.stringify({ + savedObjectIds, + }); + } } catch (err) { return Promise.reject(new Error(`Failed to stringify query: ${JSON.stringify(err)}`)); } diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/delete_timelines/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/delete_timelines/index.ts index c9a515f5566c2..602d29ae061ab 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/delete_timelines/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/delete_timelines/index.ts @@ -42,9 +42,9 @@ export const deleteTimelinesRoute = ( try { const frameworkRequest = await buildFrameworkRequest(context, security, request); - const { savedObjectIds } = request.body; + const { savedObjectIds, searchIds } = request.body; - await deleteTimeline(frameworkRequest, savedObjectIds); + await deleteTimeline(frameworkRequest, savedObjectIds, searchIds); return response.ok({ body: { data: { deleteTimeline: true } } }); } catch (err) { const error = transformError(err); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/saved_search/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/saved_search/index.ts new file mode 100644 index 0000000000000..de90a09248eba --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/saved_search/index.ts @@ -0,0 +1,22 @@ +/* + * 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 type { FrameworkRequest } from '../../../framework'; + +export const deleteSearchByTimelineId = async ( + request: FrameworkRequest, + savedSearchIds?: string[] +) => { + if (savedSearchIds !== undefined) { + const savedObjectsClient = (await request.context.core).savedObjects.client; + const objects = savedSearchIds.map((id) => ({ id, type: 'search' })); + + await savedObjectsClient.bulkDelete(objects); + } else { + return Promise.resolve(); + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts index 9cdc9189b16fa..037639464a3e8 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts @@ -38,6 +38,7 @@ import type { SavedObjectTimelineWithoutExternalRefs } from '../../../../../comm import type { FrameworkRequest } from '../../../framework'; import * as note from '../notes/saved_object'; import * as pinnedEvent from '../pinned_events'; +import { deleteSearchByTimelineId } from '../saved_search'; import { convertSavedObjectToSavedTimeline } from './convert_saved_object_to_savedtimeline'; import { pickSavedTimeline } from './pick_saved_timeline'; import { timelineSavedObjectType } from '../../saved_object_mappings'; @@ -572,18 +573,23 @@ export const resetTimeline = async ( return response; }; -export const deleteTimeline = async (request: FrameworkRequest, timelineIds: string[]) => { +export const deleteTimeline = async ( + request: FrameworkRequest, + timelineIds: string[], + searchIds?: string[] +) => { const savedObjectsClient = (await request.context.core).savedObjects.client; - await Promise.all( - timelineIds.map((timelineId) => + await Promise.all([ + ...timelineIds.map((timelineId) => Promise.all([ savedObjectsClient.delete(timelineSavedObjectType, timelineId), note.deleteNoteByTimelineId(request, timelineId), pinnedEvent.deleteAllPinnedEventsOnTimeline(request, timelineId), ]) - ) - ); + ), + deleteSearchByTimelineId(request, searchIds), + ]); }; export const copyTimeline = async (