From 13acc0cf87ba59a2ed2207eb3c28d30405214caa Mon Sep 17 00:00:00 2001 From: Jan Monschke Date: Thu, 30 Nov 2023 15:53:16 +0100 Subject: [PATCH 1/6] [Security Solution] Save timeline as new (#171027) ## Summary This is the second part of the work for fixing timeline saving issues as described in https://github.com/elastic/kibana/issues/165053. The first part, replacing auto-save with a dedicated save button was done in this PR: https://github.com/elastic/kibana/pull/169239. In this PR we're adding the `Save as new` option to the timeline save modal. This allows users to create a copy of the timeline without changing the current timeline. This is especially helpful in a multi-user environment where there are higher chances for users causing conflicting changes. This is how the feature works: https://github.com/elastic/kibana/assets/68591/dcec9938-fb28-4c3e-b916-8589c6e28441 Notice how the changes to the date range are only applied to the second timeline. The initial timeline remains unchanged. ### Changes - Added a new route `/api/timeline/_copy` that expects a timeline id and a timeline object. It will take the timeline object from the body and create a new timeline from it. Then it will fetch all related saved objects (notes, pinned events) for the timeline id in the body and create copies of those. These will be associated to the copied timeline. - Creating a copy of the associated saved search of a timeline is not done on the server because there is currently no server-side API to do that (there is an issue in the backlog for adding that though: https://github.com/elastic/kibana/issues/170580). - This means that the copy is happening in the frontend before making the copy request. While this feels inconsistent, it's the only way we can do this at the moment. - The `Save as new` switch is only shown for timelines that have been saved before. - A lot of work was done to make the change-tracking of the embedded saved search work correctly. This required to split up setting and initializing the saved search. On top, we're now storing the saved search object in the local timeline store in order to pass it to the copy request. - Various stability and type fixes in the timeline epic - There's no API documentation for the copy route because OpenAPI doesn't allow to specify private endpoints. We really don't want users to use this endpoint yet until the saved search copy can be performed on the backend. ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../copy_timeline/copy_timeline_route.ts | 15 +++ .../common/api/timeline/index.ts | 1 + .../common/api/timeline/model/api.ts | 14 +- .../security_solution/common/constants.ts | 1 + .../use_discover_in_timeline_actions.test.tsx | 63 +++++++-- .../use_discover_in_timeline_actions.tsx | 98 +++++++++----- .../public/common/lib/kibana/services.ts | 4 +- .../public/common/mock/global_state.ts | 1 + .../public/common/mock/timeline_results.ts | 2 + .../components/alerts_table/actions.test.tsx | 1 + .../action_menu/save_timeline_button.test.tsx | 21 +++ .../action_menu/save_timeline_button.tsx | 4 +- .../action_menu/save_timeline_modal.test.tsx | 14 ++ .../action_menu/save_timeline_modal.tsx | 74 ++++++---- .../flyout/action_menu/translations.ts | 7 + .../timeline/esql_tab_content/index.tsx | 14 +- .../esql_tab_content/utils/index.test.ts | 43 ++---- .../timeline/esql_tab_content/utils/index.ts | 1 - .../public/timelines/containers/api.test.ts | 121 ++++++++++++++++- .../public/timelines/containers/api.ts | 72 ++++++++++ .../timelines/store/timeline/actions.ts | 13 +- .../timelines/store/timeline/defaults.ts | 1 + .../timelines/store/timeline/epic.test.ts | 1 + .../public/timelines/store/timeline/epic.ts | 127 +++++++++++------- .../timelines/store/timeline/epic_changed.ts | 3 + .../timelines/store/timeline/epic_favorite.ts | 14 +- .../timelines/store/timeline/epic_note.ts | 17 ++- .../store/timeline/epic_pinned_event.ts | 14 +- .../timelines/store/timeline/helpers.test.ts | 1 + .../public/timelines/store/timeline/model.ts | 4 + .../timelines/store/timeline/reducer.ts | 22 +++ .../public/timelines/store/timeline/types.ts | 9 -- .../server/lib/timeline/routes/index.ts | 55 ++++++++ .../routes/timelines/copy_timeline/index.ts | 60 +++++++++ .../lib/timeline/routes/timelines/index.ts | 1 + .../saved_object/timelines/index.test.ts | 99 +++++++++++++- .../timeline/saved_object/timelines/index.ts | 57 ++++++++ .../security_solution/server/routes/index.ts | 38 +----- .../investigations/timelines/creation.cy.ts | 29 +++- .../cypress/screens/timeline.ts | 2 + .../cypress/screens/timelines.ts | 2 + .../cypress/tasks/timeline.ts | 12 ++ 42 files changed, 920 insertions(+), 232 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/api/timeline/copy_timeline/copy_timeline_route.ts create mode 100644 x-pack/plugins/security_solution/server/lib/timeline/routes/index.ts create mode 100644 x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/copy_timeline/index.ts diff --git a/x-pack/plugins/security_solution/common/api/timeline/copy_timeline/copy_timeline_route.ts b/x-pack/plugins/security_solution/common/api/timeline/copy_timeline/copy_timeline_route.ts new file mode 100644 index 0000000000000..1b7dc1d4c3566 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/timeline/copy_timeline/copy_timeline_route.ts @@ -0,0 +1,15 @@ +/* + * 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 * as rt from 'io-ts'; + +import { SavedTimelineRuntimeType } from '../model/api'; + +export const copyTimelineSchema = rt.type({ + timeline: SavedTimelineRuntimeType, + timelineIdToCopy: rt.string, +}); diff --git a/x-pack/plugins/security_solution/common/api/timeline/index.ts b/x-pack/plugins/security_solution/common/api/timeline/index.ts index 83748b596e5b1..6229b07c53a9f 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/api/timeline/index.ts @@ -20,3 +20,4 @@ export * from './persist_favorite/persist_favorite_route'; export * from './persist_note/persist_note_route'; export * from './pinned_events/pinned_events_route'; export * from './install_prepackaged_timelines/install_prepackaged_timelines'; +export * from './copy_timeline/copy_timeline_route'; diff --git a/x-pack/plugins/security_solution/common/api/timeline/model/api.ts b/x-pack/plugins/security_solution/common/api/timeline/model/api.ts index c423b2a4418bb..5237772ef5e1c 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/model/api.ts +++ b/x-pack/plugins/security_solution/common/api/timeline/model/api.ts @@ -441,10 +441,16 @@ export const TimelineResponseType = runtimeTypes.type({ }), }); -export const TimelineErrorResponseType = runtimeTypes.type({ - status_code: runtimeTypes.number, - message: runtimeTypes.string, -}); +export const TimelineErrorResponseType = runtimeTypes.union([ + runtimeTypes.type({ + status_code: runtimeTypes.number, + message: runtimeTypes.string, + }), + runtimeTypes.type({ + statusCode: runtimeTypes.number, + message: runtimeTypes.string, + }), +]); export type TimelineErrorResponse = runtimeTypes.TypeOf; export type TimelineResponse = runtimeTypes.TypeOf; diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 48ae1a940f615..e50533f223928 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -296,6 +296,7 @@ export const TIMELINE_DRAFT_URL = `${TIMELINE_URL}/_draft` as const; export const TIMELINE_EXPORT_URL = `${TIMELINE_URL}/_export` as const; export const TIMELINE_IMPORT_URL = `${TIMELINE_URL}/_import` as const; export const TIMELINE_PREPACKAGED_URL = `${TIMELINE_URL}/_prepackaged` as const; +export const TIMELINE_COPY_URL = `${TIMELINE_URL}/_copy` as const; export const NOTE_URL = '/api/note' as const; export const PINNED_EVENT_URL = '/api/pinned_event' as const; diff --git a/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.test.tsx b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.test.tsx index 8463097d98d07..fddf6a1afc6e8 100644 --- a/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.test.tsx @@ -23,6 +23,7 @@ import { useKibana } from '../../lib/kibana'; import type { State } from '../../store'; import { createStore } from '../../store'; import { TimelineId } from '../../../../common/types'; +import * as timelineActions from '../../../timelines/store/timeline/actions'; import type { ComponentType, FC, PropsWithChildren } from 'react'; import React from 'react'; import type { DataView } from '@kbn/data-views-plugin/common'; @@ -35,6 +36,17 @@ let mockDiscoverStateContainerRef = { }; jest.mock('../../lib/kibana'); + +const mockDispatch = jest.fn(); + +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + ...actual, + useDispatch: () => mockDispatch, + }; +}); + const mockState: State = { ...mockGlobalState, timeline: { @@ -239,7 +251,7 @@ describe('useDiscoverInTimelineActions', () => { }) ); }); - it('should send update request when savedSearchId is already available', async () => { + it('should initialize saved search when it is not set on the timeline model yet', async () => { const localMockState: State = { ...mockGlobalState, timeline: { @@ -262,22 +274,49 @@ describe('useDiscoverInTimelineActions', () => { await result.current.updateSavedSearch(savedSearchMock, TimelineId.active); }); - expect(startServicesMock.savedSearch.save).toHaveBeenNthCalledWith( + expect(mockDispatch).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ - timeRestore: true, - timeRange: { - from: 'now-20d', - to: 'now', + timelineActions.initializeSavedSearch({ + id: TimelineId.active, + savedSearch: savedSearchMock, + }) + ); + }); + + it('should update saved search when it has changes', async () => { + const changedSavedSearchMock = { ...savedSearchMock, title: 'changed' }; + const localMockState: State = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById[TimelineId.active], + title: 'Active Timeline', + description: 'Active Timeline Description', + savedSearchId: 'saved_search_id', + savedSearch: savedSearchMock, + }, }, - tags: ['security-solution-default'], - id: 'saved_search_id', - }), - expect.objectContaining({ - copyOnSave: false, + }, + }; + + const LocalTestProvider = getTestProviderWithCustomState(localMockState); + const { result } = renderTestHook(LocalTestProvider); + await act(async () => { + await result.current.updateSavedSearch(changedSavedSearchMock, TimelineId.active); + }); + + expect(mockDispatch).toHaveBeenNthCalledWith( + 1, + timelineActions.updateSavedSearch({ + id: TimelineId.active, + savedSearch: changedSavedSearchMock, }) ); }); + it('should raise appropriate notification in case of any error in saving discover saved search', () => {}); }); }); 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 55b65f6f4683c..3479612b8e7b2 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 @@ -7,14 +7,13 @@ import type { DiscoverStateContainer } from '@kbn/discover-plugin/public'; import type { SaveSavedSearchOptions } from '@kbn/saved-search-plugin/public'; +import { useMemo, useCallback, useRef } from 'react'; import type { RefObject } from 'react'; -import { useMemo, useCallback } from 'react'; import { useDispatch } from 'react-redux'; import type { SavedSearch } from '@kbn/saved-search-plugin/common'; import type { DiscoverAppState } from '@kbn/discover-plugin/public/application/main/services/discover_app_state_container'; import type { TimeRange } from '@kbn/es-query'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { endTimelineSaving, startTimelineSaving } from '../../../timelines/store/timeline/actions'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import { TimelineId } from '../../../../common/types'; import { timelineActions, timelineSelectors } from '../../../timelines/store/timeline'; @@ -57,6 +56,10 @@ export const useDiscoverInTimelineActions = ( ); const { savedSearchId } = timeline; + // We're using a ref here to prevent a cyclic hook-dependency chain of updateSavedSearch + const timelineRef = useRef(timeline); + timelineRef.current = timeline; + const queryClient = useQueryClient(); const { mutateAsync: saveSavedSearch } = useMutation({ @@ -177,60 +180,90 @@ export const useDiscoverInTimelineActions = ( * */ const updateSavedSearch = useCallback( async (savedSearch: SavedSearch, timelineId: string) => { - dispatch( - startTimelineSaving({ - id: timelineId, - }) - ); savedSearch.timeRestore = true; savedSearch.timeRange = savedSearch.timeRange ?? discoverDataService.query.timefilter.timefilter.getTime(); savedSearch.tags = ['security-solution-default']; + // If there is already a saved search, only update the local state if (savedSearchId) { savedSearch.id = savedSearchId; - } - try { - const response = await persistSavedSearch(savedSearch, { - onTitleDuplicate: () => {}, - copyOnSave: !savedSearchId, - }); - - if (!response || !response.id) { - throw new Error('Unknown Error occured'); + if (!timelineRef.current.savedSearch) { + dispatch( + timelineActions.initializeSavedSearch({ + id: TimelineId.active, + savedSearch, + }) + ); + } else { + dispatch( + timelineActions.updateSavedSearch({ + id: TimelineId.active, + savedSearch, + }) + ); } + } else { + // If no saved search exists. Create a new saved search instance and associate it with the timeline. + try { + dispatch( + timelineActions.startTimelineSaving({ + id: TimelineId.active, + }) + ); + const response = await persistSavedSearch(savedSearch, { + onTitleDuplicate: () => {}, + copyOnSave: !savedSearchId, + }); + + if (!response || !response.id) { + throw new Error('Unknown Error occured'); + } - if (!savedSearchId) { + if (!savedSearchId) { + dispatch( + timelineActions.updateSavedSearchId({ + id: TimelineId.active, + savedSearchId: response.id, + }) + ); + // Also save the timeline, this will only happen once, in case there is no saved search id yet + dispatch(timelineActions.saveTimeline({ id: TimelineId.active, saveAsNew: false })); + } + } catch (err) { + addError(DISCOVER_SEARCH_SAVE_ERROR_TITLE, { + title: DISCOVER_SEARCH_SAVE_ERROR_TITLE, + toastMessage: String(err), + }); dispatch( - timelineActions.updateSavedSearchId({ + timelineActions.endTimelineSaving({ id: TimelineId.active, - savedSearchId: response.id, }) ); - // Also save the timeline, this will only happen once, in case there is no saved search id yet - dispatch(timelineActions.saveTimeline({ id: TimelineId.active })); } - } catch (err) { - addError(DISCOVER_SEARCH_SAVE_ERROR_TITLE, { - title: DISCOVER_SEARCH_SAVE_ERROR_TITLE, - toastMessage: String(err), - }); - } finally { - dispatch( - endTimelineSaving({ - id: timelineId, - }) - ); } }, [persistSavedSearch, savedSearchId, addError, dispatch, discoverDataService] ); + const initializeLocalSavedSearch = useCallback( + async (savedSearch: SavedSearch, timelineId: string) => { + dispatch( + timelineActions.initializeSavedSearch({ + id: TimelineId.active, + savedSearch, + }) + ); + }, + [dispatch] + ); + const actions = useMemo( () => ({ resetDiscoverAppState, restoreDiscoverAppStateFromSavedSearch, updateSavedSearch, + initializeLocalSavedSearch, getAppStateFromSavedSearch, getDefaultDiscoverAppState, }), @@ -238,6 +271,7 @@ export const useDiscoverInTimelineActions = ( resetDiscoverAppState, restoreDiscoverAppStateFromSavedSearch, updateSavedSearch, + initializeLocalSavedSearch, getAppStateFromSavedSearch, getDefaultDiscoverAppState, ] diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/services.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/services.ts index fff266b8a5cda..a3d67524935cc 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/services.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/services.ts @@ -9,7 +9,7 @@ import type { CoreStart } from '@kbn/core/public'; import type { StartPlugins } from '../../../types'; type GlobalServices = Pick & - Pick; + Pick; export class KibanaServices { private static buildFlavor?: string; @@ -30,6 +30,7 @@ export class KibanaServices { uiSettings, notifications, expressions, + savedSearch, }: GlobalServices & { kibanaBranch: string; kibanaVersion: string; @@ -44,6 +45,7 @@ export class KibanaServices { unifiedSearch, notifications, expressions, + savedSearch, }; this.kibanaBranch = kibanaBranch; this.kibanaVersion = kibanaVersion; diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index ea73a893b6ad7..f770a26405fef 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -372,6 +372,7 @@ export const mockGlobalState: State = { itemsPerPageOptions: [10, 25, 50, 100], savedSearchId: null, isDiscoverSavedSearchLoaded: false, + savedSearch: null, isDataProviderVisible: true, }, }, diff --git a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts index ce52132282798..ba567b623ab51 100644 --- a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts +++ b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts @@ -2027,6 +2027,7 @@ export const mockTimelineModel: TimelineModel = { templateTimelineVersion: null, version: '1', savedSearchId: null, + savedSearch: null, isDataProviderVisible: false, }; @@ -2209,6 +2210,7 @@ export const defaultTimelineProps: CreateTimelineProps = { version: null, savedSearchId: null, isDiscoverSavedSearchLoaded: false, + savedSearch: null, isDataProviderVisible: false, }, to: '2018-11-05T19:03:25.937Z', diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index da975ca4a9564..6f7521c3c1d60 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -452,6 +452,7 @@ describe('alert actions', () => { templateTimelineVersion: null, version: null, savedSearchId: null, + savedSearch: null, isDiscoverSavedSearchLoaded: false, isDataProviderVisible: false, }, diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_button.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_button.test.tsx index a7259e256bd7e..92bc0be3f54e1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_button.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_button.test.tsx @@ -64,7 +64,28 @@ describe('SaveTimelineButton', () => { expect(screen.getByRole('button')).toBeDisabled(); }); + it('should disable the save timeline button when the timeline is immutable', () => { + (useUserPrivileges as jest.Mock).mockReturnValue({ + kibanaSecuritySolutionsPrivileges: { crud: true }, + }); + (getTimelineStatusByIdSelector as jest.Mock).mockReturnValue(() => ({ + status: TimelineStatus.immutable, + })); + render( + + + + ); + expect(screen.getByRole('button')).toBeDisabled(); + }); + describe('with draft timeline', () => { + beforeAll(() => { + (getTimelineStatusByIdSelector as jest.Mock).mockReturnValue(() => ({ + status: TimelineStatus.draft, + })); + }); + it('should not show the save modal if user does not have write access', async () => { (useUserPrivileges as jest.Mock).mockReturnValue({ kibanaSecuritySolutionsPrivileges: { crud: false }, diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_button.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_button.tsx index 6cc0686acbff4..5f52f67185b5c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_button.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_button.tsx @@ -42,7 +42,7 @@ export const SaveTimelineButton = React.memo(({ timelin // TODO: User may have Crud privileges but they may not have access to timeline index. // Do we need to check that? const { - kibanaSecuritySolutionsPrivileges: { crud: canEditTimeline }, + kibanaSecuritySolutionsPrivileges: { crud: canEditTimelinePrivilege }, } = useUserPrivileges(); const getTimelineStatus = useMemo(() => getTimelineStatusByIdSelector(), []); const { @@ -60,6 +60,8 @@ export const SaveTimelineButton = React.memo(({ timelin return !valueFromStorage; }, }); + + const canEditTimeline = canEditTimelinePrivilege && timelineStatus !== TimelineStatus.immutable; // Why are we checking for so many flags here? // The tour popup should only show when timeline is fully populated and all necessary // elements are visible on screen. If we would not check for all these flags, the tour diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_modal.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_modal.test.tsx index 33fa8f17880f3..43cdb85da8c30 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_modal.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_modal.test.tsx @@ -114,6 +114,13 @@ describe('EditTimelineModal', () => { }); expect(component.find('[data-test-subj="save-button"]').exists()).toEqual(true); }); + + test('Does not show save as new switch', () => { + const component = mount(, { + wrappingComponent: TestProviders, + }); + expect(component.find('[data-test-subj="save-as-new-switch"]').exists()).toEqual(false); + }); }); describe('update timeline', () => { @@ -192,6 +199,13 @@ describe('EditTimelineModal', () => { }); expect(component.find('[data-test-subj="save-button"]').exists()).toEqual(true); }); + + test('Show save as new switch', () => { + const component = mount(, { + wrappingComponent: TestProviders, + }); + expect(component.find('[data-test-subj="save-as-new-switch"]').exists()).toEqual(true); + }); }); describe('showWarning', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_modal.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_modal.tsx index 0e76facc45040..10154901f2db7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_modal.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/save_timeline_modal.tsx @@ -16,8 +16,10 @@ import { EuiSpacer, EuiProgress, EuiCallOut, + EuiSwitch, } from '@elastic/eui'; -import React, { useCallback, useEffect, useMemo } from 'react'; +import type { EuiSwitchEvent } from '@elastic/eui'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch } from 'react-redux'; import usePrevious from 'react-use/lib/usePrevious'; @@ -62,6 +64,7 @@ export const SaveTimelineModal = React.memo( getTimeline(state, timelineId) ) ); + const isUnsaved = status === TimelineStatus.draft; const prevIsSaving = usePrevious(isSaving); const dispatch = useDispatch(); // Resetting the timeline by replacing the active one with a new empty one @@ -69,6 +72,12 @@ export const SaveTimelineModal = React.memo( timelineId: TimelineId.active, timelineType: TimelineType.default, }); + const [saveAsNewTimeline, setSaveAsNewTimeline] = useState(false); + + const onSaveAsNewChanged = useCallback( + (e: EuiSwitchEvent) => setSaveAsNewTimeline(e.target.checked), + [] + ); const handleSubmit = useCallback( (titleAndDescription, isValid) => { @@ -79,12 +88,13 @@ export const SaveTimelineModal = React.memo( ...titleAndDescription, }) ); - dispatch(timelineActions.saveTimeline({ id: timelineId })); + + dispatch(timelineActions.saveTimeline({ id: timelineId, saveAsNew: saveAsNewTimeline })); } return Promise.resolve(); }, - [dispatch, timelineId] + [dispatch, timelineId, saveAsNewTimeline] ); const initialState = useMemo( @@ -232,30 +242,40 @@ export const SaveTimelineModal = React.memo( - - - - {closeModalText} - - - - - {saveButtonTitle} - - + + {!isUnsaved ? ( + + ) : null} + + + + {closeModalText} + + + + + {saveButtonTitle} + + + diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/translations.ts index dd7f7e6a6e95f..09b022d82faf2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/translations.ts @@ -166,3 +166,10 @@ export const SAVE_TOUR_TITLE = i18n.translate( defaultMessage: 'Timeline changes now require manual saves', } ); + +export const SAVE_AS_NEW = i18n.translate( + 'xpack.securitySolution.timeline.saveTimeline.modal.saveAsNew', + { + defaultMessage: 'Save as new timeline', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/esql_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/esql_tab_content/index.tsx index 25e8452e35d6d..c5762e0f0a45d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/esql_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/esql_tab_content/index.tsx @@ -14,7 +14,7 @@ import type { ScopedHistory } from '@kbn/core/public'; import type { Subscription } from 'rxjs'; import type { DataView } from '@kbn/data-views-plugin/common'; import { useQuery } from '@tanstack/react-query'; -import { debounce, isEqualWith } from 'lodash'; +import { isEqualWith } from 'lodash'; import type { SavedSearch } from '@kbn/saved-search-plugin/common'; import type { TimeRange } from '@kbn/es-query'; import { useDispatch } from 'react-redux'; @@ -70,6 +70,7 @@ export const DiscoverTabContent: FC = ({ timelineId }) setDiscoverStateContainer, getAppStateFromSavedSearch, updateSavedSearch, + initializeLocalSavedSearch, restoreDiscoverAppStateFromSavedSearch, resetDiscoverAppState, getDefaultDiscoverAppState, @@ -170,11 +171,6 @@ export const DiscoverTabContent: FC = ({ timelineId }) const combinedDiscoverSavedSearchStateRef = useRef(); - const debouncedUpdateSavedSearch = useMemo( - () => debounce(updateSavedSearch, 300), - [updateSavedSearch] - ); - useEffect(() => { if (isFetching) return; if (!isDiscoverSavedSearchLoaded) return; @@ -187,11 +183,10 @@ export const DiscoverTabContent: FC = ({ timelineId }) if (!index) return; if (!latestState || combinedDiscoverSavedSearchStateRef.current === latestState) return; if (isEqualWith(latestState, savedSearchById, savedSearchComparator)) return; - debouncedUpdateSavedSearch(latestState, timelineId); + updateSavedSearch(latestState, timelineId); combinedDiscoverSavedSearchStateRef.current = latestState; }, [ getCombinedDiscoverSavedSearchState, - debouncedUpdateSavedSearch, savedSearchById, updateSavedSearch, isDiscoverSavedSearchLoaded, @@ -230,6 +225,7 @@ export const DiscoverTabContent: FC = ({ timelineId }) let savedSearchAppState; if (savedSearchId) { const localSavedSearch = await savedSearchService.get(savedSearchId); + initializeLocalSavedSearch(localSavedSearch, timelineId); savedSearchAppState = getAppStateFromSavedSearch(localSavedSearch); } @@ -299,6 +295,8 @@ export const DiscoverTabContent: FC = ({ timelineId }) savedSearchId, savedSearchService, getDefaultDiscoverAppState, + timelineId, + initializeLocalSavedSearch, ] ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/esql_tab_content/utils/index.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/esql_tab_content/utils/index.test.ts index 3b25737b25278..bc7cb3e2f8a0a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/esql_tab_content/utils/index.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/esql_tab_content/utils/index.test.ts @@ -14,57 +14,32 @@ const customQuery = { query: '_id: *', }; -const firstDataViewMock = buildDataViewMock({ +const dataViewMock = buildDataViewMock({ name: 'first-data-view', fields: shallowMockedFields, }); -const secondDataViewMock = buildDataViewMock({ - name: 'second-data-view', - fields: shallowMockedFields, -}); - describe('savedSearchComparator', () => { - const firstMockSavedSearch = { + const mockSavedSearch = { id: 'first', title: 'first title', breakdownField: 'firstBreakdown Field', searchSource: createSearchSourceMock({ - index: firstDataViewMock, + index: dataViewMock, query: customQuery, }), }; - const secondMockSavedSearch = { - id: 'second', - title: 'second title', - breakdownField: 'second Breakdown Field', - searchSource: createSearchSourceMock({ - index: secondDataViewMock, - query: customQuery, - }), - }; it('should result true when saved search is same', () => { - const result = savedSearchComparator(firstMockSavedSearch, { ...firstMockSavedSearch }); + const result = savedSearchComparator(mockSavedSearch, { ...mockSavedSearch }); expect(result).toBe(true); }); - it('should return false index is different', () => { - const newMockedSavedSearch = { - ...firstMockSavedSearch, - searchSource: secondMockSavedSearch.searchSource, - }; - - const result = savedSearchComparator(firstMockSavedSearch, newMockedSavedSearch); - - expect(result).toBe(false); - }); - it('should return false when query is different', () => { const newMockedSavedSearch = { - ...firstMockSavedSearch, + ...mockSavedSearch, searchSource: createSearchSourceMock({ - index: firstDataViewMock, + index: dataViewMock, query: { ...customQuery, query: '*', @@ -72,17 +47,17 @@ describe('savedSearchComparator', () => { }), }; - const result = savedSearchComparator(firstMockSavedSearch, newMockedSavedSearch); + const result = savedSearchComparator(mockSavedSearch, newMockedSavedSearch); expect(result).toBe(false); }); it('should result false when title is different', () => { const newMockedSavedSearch = { - ...firstMockSavedSearch, + ...mockSavedSearch, title: 'new-title', }; - const result = savedSearchComparator(firstMockSavedSearch, newMockedSavedSearch); + const result = savedSearchComparator(mockSavedSearch, newMockedSavedSearch); expect(result).toBe(false); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/esql_tab_content/utils/index.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/esql_tab_content/utils/index.ts index 26340c12add52..1f908f5a5fe6f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/esql_tab_content/utils/index.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/esql_tab_content/utils/index.ts @@ -29,7 +29,6 @@ export const savedSearchComparator = ( 'sort', 'timeRange', 'fields.filter', - 'fields.index.id', 'fields.query', 'title', 'description', diff --git a/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts b/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts index 93ef3f5b9cab4..a055b028036e3 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts @@ -5,10 +5,12 @@ * 2.0. */ +import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks'; +import { buildDataViewMock, shallowMockedFields } from '@kbn/discover-utils/src/__mocks__'; import * as api from './api'; import { KibanaServices } from '../../common/lib/kibana'; import { TimelineType, TimelineStatus } from '../../../common/api/timeline'; -import { TIMELINE_DRAFT_URL, TIMELINE_URL } from '../../../common/constants'; +import { TIMELINE_DRAFT_URL, TIMELINE_URL, TIMELINE_COPY_URL } from '../../../common/constants'; import type { ImportDataProps } from '../../detection_engine/rule_management/logic/types'; jest.mock('../../common/lib/kibana', () => { @@ -18,6 +20,7 @@ jest.mock('../../common/lib/kibana', () => { http: { fetch: jest.fn(), }, + savedSearch: jest.fn(), })), }, }; @@ -448,3 +451,119 @@ describe('cleanDraftTimeline', () => { }); }); }); + +describe('copyTimeline', () => { + const mockPostTimelineResponse = { + data: { + persistTimeline: { + timeline: { + ...timelineData, + savedObjectId: '9d5693e0-a42a-11ea-b8f4-c5434162742a', + version: 'WzMzMiwxXQ==', + }, + }, + }, + }; + + const saveSavedSearchMock = jest.fn(); + const postMock = jest.fn(); + const initialSavedSearchId = 'initialId'; + const newSavedSearchId = 'newId-230820349807209752'; + + const customQuery = { + language: 'kuery', + query: '_id: *', + }; + + const dataViewMock = buildDataViewMock({ + name: 'first-data-view', + fields: shallowMockedFields, + }); + + const mockSavedSearch = { + id: initialSavedSearchId, + title: 'first title', + breakdownField: 'firstBreakdown Field', + searchSource: createSearchSourceMock({ + index: dataViewMock, + query: customQuery, + }), + }; + + beforeAll(() => { + jest.resetAllMocks(); + jest.resetModules(); + + (KibanaServices.get as jest.Mock).mockReturnValue({ + http: { + post: postMock.mockReturnValue(mockPostTimelineResponse), + }, + savedSearch: { + save: saveSavedSearchMock.mockImplementation(() => newSavedSearchId), + }, + }); + }); + + it('creates a new saved search when a saved search object is passed', async () => { + await api.copyTimeline({ + timelineId: 'test', + timeline: { + ...timelineData, + savedSearchId: 'test', + }, + savedSearch: mockSavedSearch, + }); + + // 'id' should be removed + expect(saveSavedSearchMock).toHaveBeenCalled(); + expect(saveSavedSearchMock).not.toHaveBeenCalledWith( + expect.objectContaining({ + id: initialSavedSearchId, + }) + ); + + // The new saved search id is sent to the server + expect(postMock).toHaveBeenCalledWith( + TIMELINE_COPY_URL, + expect.objectContaining({ + body: expect.stringContaining(newSavedSearchId), + }) + ); + }); + + it('applies the timeline changes before sending the POST request', async () => { + const ridiculousTimelineTitle = 'Wow, what a weirt timeline title'; + await api.copyTimeline({ + timelineId: 'test', + timeline: { + ...timelineData, + title: ridiculousTimelineTitle, + savedSearchId: 'test', + }, + savedSearch: mockSavedSearch, + }); + + // The new saved search id is sent to the server + expect(postMock).toHaveBeenCalledWith( + TIMELINE_COPY_URL, + expect.objectContaining({ + body: expect.stringContaining(ridiculousTimelineTitle), + }) + ); + }); + + it('does not save a saved search for timelines without `savedSearchId`', async () => { + jest.clearAllMocks(); + + await api.copyTimeline({ + timelineId: 'test', + timeline: { + ...timelineData, + savedSearchId: null, + }, + savedSearch: mockSavedSearch, + }); + + expect(saveSavedSearchMock).not.toHaveBeenCalled(); + }); +}); 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 72a75027e417e..f39143bbfa767 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/api.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/api.ts @@ -11,6 +11,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { isEmpty } from 'lodash'; import { throwErrors } from '@kbn/cases-plugin/common'; +import type { SavedSearch } from '@kbn/saved-search-plugin/common'; import type { TimelineResponse, @@ -41,6 +42,7 @@ import { TIMELINE_PREPACKAGED_URL, TIMELINE_RESOLVE_URL, TIMELINES_URL, + TIMELINE_COPY_URL, TIMELINE_FAVORITE_URL, } from '../../../common/constants'; @@ -61,6 +63,7 @@ interface RequestPostTimeline { interface RequestPatchTimeline extends RequestPostTimeline { timelineId: T; version: T; + savedSearch?: SavedSearch | null; } type RequestPersistTimeline = RequestPostTimeline & Partial>; @@ -130,6 +133,7 @@ const patchTimeline = async ({ timelineId, timeline, version, + savedSearch, }: RequestPatchTimeline): Promise => { let response = null; let requestBody = null; @@ -138,6 +142,19 @@ const patchTimeline = async ({ } catch (err) { return Promise.reject(new Error(`Failed to stringify query: ${JSON.stringify(err)}`)); } + + try { + if (timeline.savedSearchId && savedSearch) { + const { savedSearch: savedSearchService } = KibanaServices.get(); + await savedSearchService.save(savedSearch, { + onTitleDuplicate: () => ({}), + copyOnSave: false, + }); + } + } catch (e) { + return Promise.reject(new Error(`Failed to copy saved search: ${timeline.savedSearchId}`)); + } + try { response = await KibanaServices.get().http.patch(TIMELINE_URL, { method: 'PATCH', @@ -153,10 +170,63 @@ const patchTimeline = async ({ return decodeTimelineResponse(response); }; +/** + * Creates a copy of the timeline with the given id. It will also apply changes to the original timeline + * which are passed as `timeline` here. + */ +export const copyTimeline = async ({ + timelineId, + timeline, + savedSearch, +}: RequestPersistTimeline): Promise => { + let response = null; + let requestBody = null; + let newSavedSearchId = null; + + try { + if (timeline.savedSearchId && savedSearch) { + const { savedSearch: savedSearchService } = KibanaServices.get(); + const savedSearchCopy = { ...savedSearch }; + // delete the id and change the title to make sure we can copy the saved search + delete savedSearchCopy.id; + newSavedSearchId = await savedSearchService.save(savedSearchCopy, { + onTitleDuplicate: () => ({}), + copyOnSave: false, + }); + } + } catch (e) { + return Promise.reject(new Error(`Failed to copy saved search: ${timeline.savedSearchId}`)); + } + + try { + requestBody = JSON.stringify({ + timeline: { ...timeline, savedSearchId: newSavedSearchId || timeline.savedSearchId }, + timelineIdToCopy: timelineId, + }); + } catch (err) { + return Promise.reject(new Error(`Failed to stringify query: ${JSON.stringify(err)}`)); + } + + try { + response = await KibanaServices.get().http.post(TIMELINE_COPY_URL, { + method: 'POST', + body: requestBody, + version: '1', + }); + } catch (err) { + // For Future developer + // We are not rejecting our promise here because we had issue with our RXJS epic + // the issue we were not able to pass the right object to it so we did manage the error in the success + return Promise.resolve(decodeTimelineErrorResponse(err.body)); + } + return decodeTimelineResponse(response); +}; + export const persistTimeline = async ({ timelineId, timeline, version, + savedSearch, }: RequestPersistTimeline): Promise => { try { if (isEmpty(timelineId) && timeline.status === TimelineStatus.draft && timeline) { @@ -187,6 +257,7 @@ export const persistTimeline = async ({ ...templateTimelineInfo, }, version: draftTimeline.data.persistTimeline.timeline.version ?? '', + savedSearch, }); } @@ -198,6 +269,7 @@ export const persistTimeline = async ({ timelineId: timelineId ?? '-1', timeline, version: version ?? '', + savedSearch, }); } catch (err) { if (err.status_code === 403 || err.body.status_code === 403) { diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts index 15e4f4c069c3b..8440a534ad45d 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts @@ -7,6 +7,7 @@ import actionCreatorFactory from 'typescript-fsa'; import type { Filter } from '@kbn/es-query'; +import type { SavedSearch } from '@kbn/saved-search-plugin/common'; import type { SessionViewConfig } from '../../../../common/types'; import type { @@ -46,7 +47,7 @@ export const setInsertTimeline = actionCreator('SET_INSER export const addProvider = actionCreator<{ id: string; providers: DataProvider[] }>('ADD_PROVIDER'); -export const saveTimeline = actionCreator<{ id: string }>('SAVE_TIMELINE'); +export const saveTimeline = actionCreator<{ id: string; saveAsNew: boolean }>('SAVE_TIMELINE'); export const createTimeline = actionCreator('CREATE_TIMELINE'); @@ -273,6 +274,16 @@ export const setIsDiscoverSavedSearchLoaded = actionCreator<{ isDiscoverSavedSearchLoaded: boolean; }>('SET_IS_DISCOVER_SAVED_SEARCH_LOADED'); +export const initializeSavedSearch = actionCreator<{ + id: string; + savedSearch: SavedSearch; +}>('INITIALIZE_SAVED_SEARCH'); + +export const updateSavedSearch = actionCreator<{ + id: string; + savedSearch: SavedSearch; +}>('UPDATE_SAVED_SEARCH'); + export const setDataProviderVisibility = actionCreator<{ id: string; isDataProviderVisible: boolean; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts index e1c01f226ca78..1df0a997a2d8e 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts @@ -79,6 +79,7 @@ export const timelineDefaults: SubsetTimelineModel & isSelectAllChecked: false, filters: [], savedSearchId: null, + savedSearch: null, isDiscoverSavedSearchLoaded: false, isDataProviderVisible: false, }; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts index 13941635e5d34..9a273bc6ca14a 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts @@ -176,6 +176,7 @@ describe('Epic Timeline', () => { id: '11169110-fc22-11e9-8ca9-072f15ce2685', savedQueryId: 'my endgame timeline query', savedSearchId: null, + savedSearch: null, isDataProviderVisible: true, }; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts index 7f1c878df692b..7c072abce73e0 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts @@ -29,13 +29,13 @@ import { takeUntil, } from 'rxjs/operators'; -import type { TimelineErrorResponse, ResponseTimeline } from '../../../../common/api/timeline'; +import type { TimelineErrorResponse, TimelineResponse } from '../../../../common/api/timeline'; import type { ColumnHeaderOptions } from '../../../../common/types/timeline'; import { TimelineStatus, TimelineType } from '../../../../common/api/timeline'; import type { inputsModel } from '../../../common/store/inputs'; import { addError } from '../../../common/store/app/actions'; -import { persistTimeline } from '../../containers/api'; +import { copyTimeline, persistTimeline } from '../../containers/api'; import { ALL_TIMELINE_QUERY_ID } from '../../containers/all'; import * as i18n from '../../pages/translations'; @@ -50,13 +50,13 @@ import { setChanged, } from './actions'; import type { TimelineModel } from './model'; -import { epicPersistNote, timelineNoteActionsType } from './epic_note'; -import { epicPersistPinnedEvent, timelinePinnedEventActionsType } from './epic_pinned_event'; -import { epicPersistTimelineFavorite, timelineFavoriteActionsType } from './epic_favorite'; +import { epicPersistNote, isNoteAction } from './epic_note'; +import { epicPersistPinnedEvent, isPinnedEventAction } from './epic_pinned_event'; +import { epicPersistTimelineFavorite, isFavoriteTimelineAction } from './epic_favorite'; import { isNotNull } from './helpers'; import { dispatcherTimelinePersistQueue } from './epic_dispatcher_timeline_persistence_queue'; import { myEpicTimelineId } from './my_epic_timeline_id'; -import type { ActionTimeline, TimelineEpicDependencies } from './types'; +import type { TimelineEpicDependencies } from './types'; import type { TimelineInput } from '../../../../common/search_strategy'; const isItAtimelineAction = (timelineId: string | undefined) => @@ -133,25 +133,17 @@ export const createTimelineEpic = dispatcherTimelinePersistQueue.pipe( withLatestFrom(timeline$, notes$, timelineTimeRange$), concatMap(([objAction, timeline, notes, timelineTimeRange]) => { - const action: ActionTimeline = get('action', objAction); + const action: Action = get('action', objAction); const timelineId = myEpicTimelineId.getTimelineId(); const version = myEpicTimelineId.getTimelineVersion(); const templateTimelineId = myEpicTimelineId.getTemplateTimelineId(); const templateTimelineVersion = myEpicTimelineId.getTemplateTimelineVersion(); - if (timelineNoteActionsType.has(action.type)) { - return epicPersistNote( - action, - timeline, - notes, - action$, - timeline$, - notes$, - allTimelineQuery$ - ); - } else if (timelinePinnedEventActionsType.has(action.type)) { + if (isNoteAction(action)) { + return epicPersistNote(action, notes, action$, timeline$, notes$, allTimelineQuery$); + } else if (isPinnedEventAction(action)) { return epicPersistPinnedEvent(action, timeline, action$, timeline$, allTimelineQuery$); - } else if (timelineFavoriteActionsType.has(action.type)) { + } else if (isFavoriteTimelineAction(action)) { return epicPersistTimelineFavorite( action, timeline, @@ -159,23 +151,36 @@ export const createTimelineEpic = timeline$, allTimelineQuery$ ); - } else if (action.type === saveTimeline.type) { + } else if (isSaveTimelineAction(action)) { + const saveAction = action as unknown as ReturnType; + const savedSearch = timeline[action.payload.id].savedSearch; return from( - persistTimeline({ - timelineId, - version, - timeline: { - ...convertTimelineAsInput(timeline[action.payload.id], timelineTimeRange), - templateTimelineId, - templateTimelineVersion, - }, - }) + saveAction.payload.saveAsNew && timelineId + ? copyTimeline({ + timelineId, + timeline: { + ...convertTimelineAsInput(timeline[action.payload.id], timelineTimeRange), + templateTimelineId, + templateTimelineVersion, + }, + savedSearch, + }) + : persistTimeline({ + timelineId, + version, + timeline: { + ...convertTimelineAsInput(timeline[action.payload.id], timelineTimeRange), + templateTimelineId, + templateTimelineVersion, + }, + savedSearch, + }) ).pipe( withLatestFrom(timeline$, allTimelineQuery$, kibana$), - mergeMap(([result, recentTimeline, allTimelineQuery, kibana]) => { - const error = result as TimelineErrorResponse; - if (error.status_code != null) { - switch (error.status_code) { + mergeMap(([response, recentTimeline, allTimelineQuery, kibana]) => { + if (isTimelineErrorResponse(response)) { + const error = getErrorFromResponse(response); + switch (error?.errorCode) { // conflict case 409: kibana.notifications.toasts.addDanger({ @@ -186,7 +191,7 @@ export const createTimelineEpic = default: kibana.notifications.toasts.addDanger({ title: i18n.UPDATE_TIMELINE_ERROR_TITLE, - text: error.message ?? i18n.UPDATE_TIMELINE_ERROR_TEXT, + text: error?.message ?? i18n.UPDATE_TIMELINE_ERROR_TEXT, }); } return [ @@ -196,9 +201,8 @@ export const createTimelineEpic = ]; } - const savedTimeline = recentTimeline[action.payload.id]; - const response: ResponseTimeline = get('data.persistTimeline', result); - if (response == null) { + const unwrappedResponse = response.data.persistTimeline; + if (unwrappedResponse == null) { kibana.notifications.toasts.addDanger({ title: i18n.UPDATE_TIMELINE_ERROR_TITLE, text: i18n.UPDATE_TIMELINE_ERROR_TEXT, @@ -209,7 +213,15 @@ export const createTimelineEpic = }), ]; } - const callOutMsg = response.code === 403 ? [showCallOutUnauthorizedMsg()] : []; + + if (unwrappedResponse.code === 403) { + return [ + showCallOutUnauthorizedMsg(), + endTimelineSaving({ + id: action.payload.id, + }), + ]; + } if (allTimelineQuery.refetch != null) { (allTimelineQuery.refetch as inputsModel.Refetch)(); @@ -219,18 +231,19 @@ export const createTimelineEpic = updateTimeline({ id: action.payload.id, timeline: { - ...savedTimeline, - updated: response.timeline.updated ?? undefined, - savedObjectId: response.timeline.savedObjectId, - version: response.timeline.version, - status: response.timeline.status ?? TimelineStatus.active, - timelineType: response.timeline.timelineType ?? TimelineType.default, - templateTimelineId: response.timeline.templateTimelineId ?? null, - templateTimelineVersion: response.timeline.templateTimelineVersion ?? null, + ...recentTimeline[action.payload.id], + updated: unwrappedResponse.timeline.updated ?? undefined, + savedObjectId: unwrappedResponse.timeline.savedObjectId, + version: unwrappedResponse.timeline.version, + status: unwrappedResponse.timeline.status ?? TimelineStatus.active, + timelineType: unwrappedResponse.timeline.timelineType ?? TimelineType.default, + templateTimelineId: unwrappedResponse.timeline.templateTimelineId ?? null, + templateTimelineVersion: + unwrappedResponse.timeline.templateTimelineVersion ?? null, + savedSearchId: unwrappedResponse.timeline.savedSearchId ?? null, isSaving: false, }, }), - ...callOutMsg, setChanged({ id: action.payload.id, changed: false, @@ -238,7 +251,7 @@ export const createTimelineEpic = endTimelineSaving({ id: action.payload.id, }), - ].filter(Boolean); + ]; }), startWith(startTimelineSaving({ id: action.payload.id })), takeUntil( @@ -275,6 +288,10 @@ export const createTimelineEpic = ); }; +function isSaveTimelineAction(action: Action): action is ReturnType { + return action.type === saveTimeline.type; +} + const timelineInput: TimelineInput = { columns: null, dataProviders: null, @@ -389,3 +406,17 @@ const convertToString = (obj: unknown) => { return ''; } }; + +type PossibleResponse = TimelineResponse | TimelineErrorResponse; + +function isTimelineErrorResponse(response: PossibleResponse): response is TimelineErrorResponse { + return 'status_code' in response || 'statusCode' in response; +} + +function getErrorFromResponse(response: TimelineErrorResponse) { + if ('status_code' in response) { + return { errorCode: response.status_code, message: response.message }; + } else if ('statusCode' in response) { + return { errorCode: response.statusCode, message: response.message }; + } +} diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_changed.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_changed.ts index 430f7bc71aa54..ed4294207b308 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_changed.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_changed.ts @@ -32,6 +32,7 @@ import { setFilters, setSavedQueryId, setChanged, + updateSavedSearch, } from './actions'; /** @@ -59,6 +60,8 @@ const timelineChangedTypes = new Set([ updateSort.type, updateRange.type, upsertColumn.type, + + updateSavedSearch.type, ]); /** diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_favorite.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_favorite.ts index 990bb229761c0..ff501fb4761de 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_favorite.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_favorite.ts @@ -22,16 +22,22 @@ import { } from './actions'; import { dispatcherTimelinePersistQueue } from './epic_dispatcher_timeline_persistence_queue'; import { myEpicTimelineId } from './my_epic_timeline_id'; -import type { ActionTimeline, TimelineById } from './types'; +import type { TimelineById } from './types'; import type { inputsModel } from '../../../common/store/inputs'; import type { ResponseFavoriteTimeline } from '../../../../common/api/timeline'; import { TimelineType } from '../../../../common/api/timeline'; import { persistFavorite } from '../../containers/api'; -export const timelineFavoriteActionsType = new Set([updateIsFavorite.type]); +type FavoriteTimelineAction = ReturnType; + +const timelineFavoriteActionsType = new Set([updateIsFavorite.type]); + +export function isFavoriteTimelineAction(action: Action): action is FavoriteTimelineAction { + return timelineFavoriteActionsType.has(action.type); +} export const epicPersistTimelineFavorite = ( - action: ActionTimeline, + action: FavoriteTimelineAction, timeline: TimelineById, action$: Observable, timeline$: Observable, @@ -108,7 +114,7 @@ export const createTimelineFavoriteEpic = (): Epic => (action$) => { return action$.pipe( - filter((action) => timelineFavoriteActionsType.has(action.type)), + filter(isFavoriteTimelineAction), mergeMap((action) => { dispatcherTimelinePersistQueue.next({ action }); return EMPTY; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_note.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_note.ts index a9992d69c9260..01e612302ca31 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_note.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_note.ts @@ -26,15 +26,20 @@ import { } from './actions'; import { myEpicTimelineId } from './my_epic_timeline_id'; import { dispatcherTimelinePersistQueue } from './epic_dispatcher_timeline_persistence_queue'; -import type { ActionTimeline, TimelineById } from './types'; +import type { TimelineById } from './types'; import { persistNote } from '../../containers/notes/api'; import type { ResponseNote } from '../../../../common/api/timeline'; -export const timelineNoteActionsType = new Set([addNote.type, addNoteToEvent.type]); +type NoteAction = ReturnType; + +const timelineNoteActionsType = new Set([addNote.type, addNoteToEvent.type]); + +export function isNoteAction(action: Action): action is NoteAction { + return timelineNoteActionsType.has(action.type); +} export const epicPersistNote = ( - action: ActionTimeline, - timeline: TimelineById, + action: NoteAction, notes: NotesById, action$: Observable, timeline$: Observable, @@ -47,7 +52,7 @@ export const epicPersistNote = ( noteId: null, version: null, note: { - eventId: action.payload.eventId, + eventId: 'eventId' in action.payload ? action.payload.eventId : undefined, note: getNote(action.payload.noteId, notes), timelineId: myEpicTimelineId.getTimelineId(), }, @@ -125,7 +130,7 @@ export const createTimelineNoteEpic = (): Epic => (action$) => action$.pipe( - filter((action) => timelineNoteActionsType.has(action.type)), + filter(isNoteAction), switchMap((action) => { dispatcherTimelinePersistQueue.next({ action }); return EMPTY; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_pinned_event.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_pinned_event.ts index 9231eefb46195..b99de117e785e 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_pinned_event.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_pinned_event.ts @@ -25,13 +25,19 @@ import { } from './actions'; import { myEpicTimelineId } from './my_epic_timeline_id'; import { dispatcherTimelinePersistQueue } from './epic_dispatcher_timeline_persistence_queue'; -import type { ActionTimeline, TimelineById } from './types'; +import type { TimelineById } from './types'; import { persistPinnedEvent } from '../../containers/pinned_event/api'; -export const timelinePinnedEventActionsType = new Set([pinEvent.type, unPinEvent.type]); +type PinnedEventAction = ReturnType; + +const timelinePinnedEventActionsType = new Set([pinEvent.type, unPinEvent.type]); + +export function isPinnedEventAction(action: Action): action is PinnedEventAction { + return timelinePinnedEventActionsType.has(action.type); +} export const epicPersistPinnedEvent = ( - action: ActionTimeline, + action: PinnedEventAction, timeline: TimelineById, action$: Observable, timeline$: Observable, @@ -129,7 +135,7 @@ export const createTimelinePinnedEventEpic = (): Epic => (action$) => action$.pipe( - filter((action) => timelinePinnedEventActionsType.has(action.type)), + filter(isPinnedEventAction), mergeMap((action) => { dispatcherTimelinePersistQueue.next({ action }); return EMPTY; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.test.ts index f0d553ce75246..e0ca1b6dc681d 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.test.ts @@ -138,6 +138,7 @@ const basicTimeline: TimelineModel = { title: '', version: null, savedSearchId: null, + savedSearch: null, isDataProviderVisible: true, }; const timelineByIdMock: TimelineById = { diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts index a71d20e1b7688..47e15be86e2a1 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts @@ -7,6 +7,7 @@ import type { FilterManager } from '@kbn/data-plugin/public'; import type { Filter } from '@kbn/es-query'; +import type { SavedSearch } from '@kbn/saved-search-plugin/common'; import type { ExpandedDetailTimeline, SessionViewConfig } from '../../../../common/types'; import type { EqlOptionsSelected, @@ -135,6 +136,8 @@ export interface TimelineModel { selectAll: boolean; /* discover saved search Id */ savedSearchId: string | null; + /* local saved search object, it's not sent to the server */ + savedSearch: SavedSearch | null; isDiscoverSavedSearchLoaded?: boolean; isDataProviderVisible: boolean; /** used to mark the timeline as unsaved in the UI */ @@ -193,6 +196,7 @@ export type SubsetTimelineModel = Readonly< | 'filters' | 'filterManager' | 'savedSearchId' + | 'savedSearch' | 'isDiscoverSavedSearchLoaded' | 'isDataProviderVisible' | 'changed' diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts index 4bf2b3a2b41a4..6947b207874a7 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts @@ -58,6 +58,8 @@ import { clearEventsDeleted, clearEventsLoading, updateSavedSearchId, + updateSavedSearch, + initializeSavedSearch, setIsDiscoverSavedSearchLoaded, setDataProviderVisibility, setChanged, @@ -521,6 +523,26 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) }, }, })) + .case(initializeSavedSearch, (state, { id, savedSearch }) => ({ + ...state, + timelineById: { + ...state.timelineById, + [id]: { + ...state.timelineById[id], + savedSearch, + }, + }, + })) + .case(updateSavedSearch, (state, { id, savedSearch }) => ({ + ...state, + timelineById: { + ...state.timelineById, + [id]: { + ...state.timelineById[id], + savedSearch, + }, + }, + })) .case(setIsDiscoverSavedSearchLoaded, (state, { id, isDiscoverSavedSearchLoaded }) => ({ ...state, timelineById: { diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts index 365196a228444..cd55fb83335d2 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts @@ -5,7 +5,6 @@ * 2.0. */ -import type { Action } from 'redux'; import type { Observable } from 'rxjs'; import type { Storage } from '@kbn/kibana-utils-plugin/public'; @@ -40,14 +39,6 @@ export interface TimelineState { insertTimeline: InsertTimeline | null; } -export interface ActionTimeline extends Action { - payload: { - id: string; - eventId: string; - noteId: string; - }; -} - export interface TimelineEpicDependencies { timelineByIdSelector: (state: State) => TimelineById; timelineTimeRangeSelector: (state: State) => inputsModel.TimeRange; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/index.ts new file mode 100644 index 0000000000000..33a0b6f09d3e6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/index.ts @@ -0,0 +1,55 @@ +/* + * 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 { SetupPlugins } from '../../../plugin'; +import type { SecuritySolutionPluginRouter } from '../../../types'; +import type { ConfigType } from '../../..'; +import { + createTimelinesRoute, + deleteTimelinesRoute, + exportTimelinesRoute, + getTimelineRoute, + getTimelinesRoute, + importTimelinesRoute, + patchTimelinesRoute, + persistFavoriteRoute, + resolveTimelineRoute, + copyTimelineRoute, +} from './timelines'; +import { getDraftTimelinesRoute } from './draft_timelines/get_draft_timelines'; +import { cleanDraftTimelinesRoute } from './draft_timelines/clean_draft_timelines'; +import { installPrepackedTimelinesRoute } from './prepackaged_timelines/install_prepackaged_timelines'; + +import { persistNoteRoute, deleteNoteRoute } from './notes'; + +import { persistPinnedEventRoute } from './pinned_events'; + +export function registerTimelineRoutes( + router: SecuritySolutionPluginRouter, + config: ConfigType, + security: SetupPlugins['security'] +) { + createTimelinesRoute(router, config, security); + patchTimelinesRoute(router, config, security); + + importTimelinesRoute(router, config, security); + exportTimelinesRoute(router, config, security); + getDraftTimelinesRoute(router, config, security); + getTimelineRoute(router, config, security); + resolveTimelineRoute(router, config, security); + getTimelinesRoute(router, config, security); + cleanDraftTimelinesRoute(router, config, security); + deleteTimelinesRoute(router, config, security); + persistFavoriteRoute(router, config, security); + copyTimelineRoute(router, config, security); + + installPrepackedTimelinesRoute(router, config, security); + + persistNoteRoute(router, config, security); + deleteNoteRoute(router, config, security); + persistPinnedEventRoute(router, config, security); +} diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/copy_timeline/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/copy_timeline/index.ts new file mode 100644 index 0000000000000..3a55b4fef8086 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/copy_timeline/index.ts @@ -0,0 +1,60 @@ +/* + * 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 { transformError } from '@kbn/securitysolution-es-utils'; +import { buildRouteValidationWithExcess } from '../../../../../utils/build_validation/route_validation'; +import type { ConfigType } from '../../../../..'; +import { copyTimelineSchema } from '../../../../../../common/api/timeline'; +import { copyTimeline } from '../../../saved_object/timelines'; +import type { SecuritySolutionPluginRouter } from '../../../../../types'; +import type { SetupPlugins } from '../../../../../plugin'; +import { TIMELINE_COPY_URL } from '../../../../../../common/constants'; +import { buildSiemResponse } from '../../../../detection_engine/routes/utils'; + +import { buildFrameworkRequest } from '../../../utils/common'; + +export const copyTimelineRoute = async ( + router: SecuritySolutionPluginRouter, + _: ConfigType, + security: SetupPlugins['security'] +) => { + router.versioned + .post({ + path: TIMELINE_COPY_URL, + options: { + tags: ['access:securitySolution'], + }, + access: 'internal', + }) + .addVersion( + { + version: '1', + validate: { + request: { body: buildRouteValidationWithExcess(copyTimelineSchema) }, + }, + }, + async (context, request, response) => { + const siemResponse = buildSiemResponse(response); + + try { + const frameworkRequest = await buildFrameworkRequest(context, security, request); + const { timeline, timelineIdToCopy } = request.body; + const copiedTimeline = await copyTimeline(frameworkRequest, timeline, timelineIdToCopy); + + return response.ok({ + body: { data: { persistTimeline: copiedTimeline } }, + }); + } catch (err) { + const error = transformError(err); + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/index.ts index ba20633a65145..1f26af72a22e9 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/index.ts @@ -13,3 +13,4 @@ export { importTimelinesRoute } from './import_timelines'; export { patchTimelinesRoute } from './patch_timelines'; export { persistFavoriteRoute } from './persist_favorite'; export { resolveTimelineRoute } from './resolve_timeline'; +export { copyTimelineRoute } from './copy_timeline'; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.test.ts index 67ff652176161..9cefecda1ed2a 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.test.ts @@ -7,6 +7,7 @@ import type { FrameworkRequest } from '../../../framework'; import { mockGetTimelineValue, mockSavedObject } from '../../__mocks__/import_timelines'; +import { mockTimeline } from '../../__mocks__/create_timelines'; import { convertStringToBase64, @@ -15,10 +16,11 @@ import { getDraftTimeline, resolveTimelineOrNull, updatePartialSavedTimeline, + copyTimeline, } from '.'; import { convertSavedObjectToSavedTimeline } from './convert_saved_object_to_savedtimeline'; -import { getNotesByTimelineId } from '../notes/saved_object'; -import { getAllPinnedEventsByTimelineId } from '../pinned_events'; +import { getNotesByTimelineId, persistNote } from '../notes/saved_object'; +import { getAllPinnedEventsByTimelineId, persistPinnedEventOnTimeline } from '../pinned_events'; import { TimelineType } from '../../../../../common/api/timeline'; import type { AllTimelinesResponse, @@ -40,10 +42,12 @@ jest.mock('./convert_saved_object_to_savedtimeline', () => ({ jest.mock('../notes/saved_object', () => ({ getNotesByTimelineId: jest.fn().mockResolvedValue([]), + persistNote: jest.fn(), })); jest.mock('../pinned_events', () => ({ getAllPinnedEventsByTimelineId: jest.fn().mockResolvedValue([]), + persistPinnedEventOnTimeline: jest.fn(), })); describe('saved_object', () => { @@ -462,4 +466,95 @@ describe('saved_object', () => { }); }); }); + + describe('Copy timeline', () => { + let mockFindSavedObject: jest.Mock; + let mockRequest: FrameworkRequest; + let createSavedObject: jest.Mock; + + beforeEach(() => { + mockFindSavedObject = jest.fn().mockResolvedValue({ saved_objects: [], total: 0 }); + createSavedObject = jest.fn().mockResolvedValue({ + id: '1', + version: '2323r23', + attributes: { + ...mockGetTimelineValue, + kqlQuery: null, + }, + }); + mockRequest = { + user: { + username: 'username', + }, + context: { + core: { + savedObjects: { + client: { + find: mockFindSavedObject, + create: createSavedObject, + get: jest.fn(async () => ({ + ...mockResolvedSavedObject.saved_object, + })), + }, + }, + }, + }, + } as unknown as FrameworkRequest; + }); + + afterEach(() => { + mockFindSavedObject.mockClear(); + (getNotesByTimelineId as jest.Mock).mockClear(); + (persistNote as jest.Mock).mockClear(); + (getAllPinnedEventsByTimelineId as jest.Mock).mockClear(); + }); + + it('should resolve all associated saved objects and copy those', async () => { + const note = { + notedId: 'theNoteId', + timelineId: 'original_id', + version: '23d23f', + note: 'test note', + }; + (getNotesByTimelineId as jest.Mock).mockResolvedValue([note]); + const pinnedEvent = { + timelineId: 'original_id', + eventId: 'randomEventId', + }; + (getAllPinnedEventsByTimelineId as jest.Mock).mockResolvedValue([pinnedEvent]); + + const originalId = 'original_id'; + const res = await copyTimeline( + mockRequest, + mockTimeline as unknown as SavedTimeline, + originalId + ); + + // Resolves objects by the correct timeline id + expect(getNotesByTimelineId).toHaveBeenCalledWith(mockRequest, originalId); + expect(getAllPinnedEventsByTimelineId).toHaveBeenCalledWith(mockRequest, originalId); + + // Notes are created with the new timeline id and a copy of the original node + expect(persistNote).toHaveBeenCalledWith( + expect.objectContaining({ + noteId: null, + note: expect.objectContaining({ + ...note, + timelineId: mockResolvedTimeline.savedObjectId, + }), + overrideOwner: false, + }) + ); + + // Pinned events are created with the new timeline id and the correct event id + expect(persistPinnedEventOnTimeline).toHaveBeenCalledWith( + mockRequest, + null, + pinnedEvent.eventId, + mockResolvedTimeline.savedObjectId + ); + + expect(res.timeline.savedObjectId).toBe(mockResolvedTimeline.savedObjectId); + }); + }); }); 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 e966a2d9f9f32..9cdc9189b16fa 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 @@ -586,6 +586,63 @@ export const deleteTimeline = async (request: FrameworkRequest, timelineIds: str ); }; +export const copyTimeline = async ( + request: FrameworkRequest, + timeline: SavedTimeline, + timelineId: string +): Promise => { + const savedObjectsClient = (await request.context.core).savedObjects.client; + + // Fetch all objects that need to be copied + const [notes, pinnedEvents] = await Promise.all([ + note.getNotesByTimelineId(request, timelineId), + pinnedEvent.getAllPinnedEventsByTimelineId(request, timelineId), + ]); + + const isImmutable = timeline.status === TimelineStatus.immutable; + const userInfo = isImmutable ? ({ username: 'Elastic' } as AuthenticatedUser) : request.user; + + const timelineResponse = await createTimeline({ + savedObjectsClient, + timeline, + timelineId: null, + userInfo, + }); + + const newTimelineId = timelineResponse.timeline.savedObjectId; + + const copiedNotes = Promise.all( + notes.map((_note) => { + return note.persistNote({ + request, + noteId: null, + note: { + ..._note, + timelineId: newTimelineId, + }, + overrideOwner: false, + }); + }) + ); + + const copiedPinnedEvents = pinnedEvents.map((_pinnedEvent) => { + return pinnedEvent.persistPinnedEventOnTimeline( + request, + null, + _pinnedEvent.eventId, + newTimelineId + ); + }); + + await Promise.all([copiedNotes, copiedPinnedEvents]); + + return { + code: 200, + message: 'success', + timeline: await getSavedTimeline(request, newTimelineId), + }; +}; + const resolveBasicSavedTimeline = async (request: FrameworkRequest, timelineId: string) => { const savedObjectsClient = (await request.context.core).savedObjects.client; const { saved_object: savedObject, ...resolveAttributes } = diff --git a/x-pack/plugins/security_solution/server/routes/index.ts b/x-pack/plugins/security_solution/server/routes/index.ts index 6131faa153524..de1b9d8125f7f 100644 --- a/x-pack/plugins/security_solution/server/routes/index.ts +++ b/x-pack/plugins/security_solution/server/routes/index.ts @@ -29,28 +29,10 @@ import { querySignalsRoute } from '../lib/detection_engine/routes/signals/query_ import { setSignalsStatusRoute } from '../lib/detection_engine/routes/signals/open_close_signals_route'; import { deleteIndexRoute } from '../lib/detection_engine/routes/index/delete_index_route'; import { readPrivilegesRoute } from '../lib/detection_engine/routes/privileges/read_privileges_route'; -import { - createTimelinesRoute, - deleteTimelinesRoute, - exportTimelinesRoute, - getTimelineRoute, - getTimelinesRoute, - importTimelinesRoute, - patchTimelinesRoute, - persistFavoriteRoute, - resolveTimelineRoute, -} from '../lib/timeline/routes/timelines'; -import { getDraftTimelinesRoute } from '../lib/timeline/routes/draft_timelines/get_draft_timelines'; -import { cleanDraftTimelinesRoute } from '../lib/timeline/routes/draft_timelines/clean_draft_timelines'; - -import { persistNoteRoute, deleteNoteRoute } from '../lib/timeline/routes/notes'; - -import { persistPinnedEventRoute } from '../lib/timeline/routes/pinned_events'; import type { SetupPlugins, StartPlugins } from '../plugin'; import type { ConfigType } from '../config'; import type { ITelemetryEventsSender } from '../lib/telemetry/sender'; -import { installPrepackedTimelinesRoute } from '../lib/timeline/routes/prepackaged_timelines/install_prepackaged_timelines'; import type { CreateRuleOptions, CreateSecurityRuleTypeWrapperProps, @@ -81,6 +63,7 @@ import { riskEngineStatusRoute, riskEnginePrivilegesRoute, } from '../lib/entity_analytics/risk_engine/routes'; +import { registerTimelineRoutes } from '../lib/timeline/routes'; import { riskScoreCalculationRoute } from '../lib/entity_analytics/risk_score/routes/calculation'; import { riskScorePreviewRoute } from '../lib/entity_analytics/risk_score/routes/preview'; import { assetCriticalityStatusRoute } from '../lib/entity_analytics/asset_criticality/routes'; @@ -122,24 +105,7 @@ export const initRoutes = ( registerResolverRoutes(router, getStartServices, config); - createTimelinesRoute(router, config, security); - patchTimelinesRoute(router, config, security); - - importTimelinesRoute(router, config, security); - exportTimelinesRoute(router, config, security); - getDraftTimelinesRoute(router, config, security); - getTimelineRoute(router, config, security); - resolveTimelineRoute(router, config, security); - getTimelinesRoute(router, config, security); - cleanDraftTimelinesRoute(router, config, security); - deleteTimelinesRoute(router, config, security); - persistFavoriteRoute(router, config, security); - - installPrepackedTimelinesRoute(router, config, security); - - persistNoteRoute(router, config, security); - deleteNoteRoute(router, config, security); - persistPinnedEventRoute(router, config, security); + registerTimelineRoutes(router, config, security); // Detection Engine Signals routes that have the REST endpoints of /api/detection_engine/signals // POST /api/detection_engine/signals/status diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/creation.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/creation.cy.ts index 3a987f6509690..c9163f7da515b 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/creation.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/creation.cy.ts @@ -21,6 +21,7 @@ import { SAVE_TIMELINE_ACTION_BTN, SAVE_TIMELINE_TOOLTIP, } from '../../../screens/timeline'; +import { ROWS } from '../../../screens/timelines'; import { createTimelineTemplate } from '../../../tasks/api_calls/timelines'; import { deleteTimelines } from '../../../tasks/api_calls/common'; @@ -41,9 +42,11 @@ import { pinFirstEvent, populateTimeline, addNameToTimelineAndSave, + addNameToTimelineAndSaveAsNew, } from '../../../tasks/timeline'; +import { createTimeline } from '../../../tasks/timelines'; -import { OVERVIEW_URL, TIMELINE_TEMPLATES_URL } from '../../../urls/navigation'; +import { OVERVIEW_URL, TIMELINE_TEMPLATES_URL, TIMELINES_URL } from '../../../urls/navigation'; describe('Create a timeline from a template', { tags: ['@ess', '@serverless'] }, () => { before(() => { @@ -179,4 +182,28 @@ describe('Timelines', (): void => { .should('match', /^Has unsaved changes/); }); }); + + describe('saves timeline as new', () => { + before(() => { + deleteTimelines(); + login(); + visitWithTimeRange(TIMELINES_URL); + }); + + it('should save timelines as new', { tags: ['@ess', '@serverless'] }, () => { + cy.get(ROWS).should('have.length', '0'); + + createTimeline(); + addNameToTimelineAndSave('First'); + addNameToTimelineAndSaveAsNew('Second'); + closeTimeline(); + + cy.get(ROWS).should('have.length', '2'); + cy.get(ROWS) + .first() + .invoke('text') + .should('match', /Second/); + cy.get(ROWS).last().invoke('text').should('match', /First/); + }); + }); }); diff --git a/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts b/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts index e84d38c78fd8b..35ed8f63b216b 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts @@ -261,6 +261,8 @@ export const TIMELINE_SAVE_MODAL = '[data-test-subj="save-timeline-modal"]'; export const TIMELINE_EDIT_MODAL_SAVE_BUTTON = '[data-test-subj="save-button"]'; +export const TIMELINE_EDIT_MODAL_SAVE_AS_NEW_SWITCH = '[data-test-subj="save-as-new-switch"]'; + export const TIMELINE_EXIT_FULL_SCREEN_BUTTON = '[data-test-subj="exit-full-screen"]'; export const TIMELINE_FLYOUT_WRAPPER = '[data-test-subj="flyout-pane"]'; diff --git a/x-pack/test/security_solution_cypress/cypress/screens/timelines.ts b/x-pack/test/security_solution_cypress/cypress/screens/timelines.ts index b7d12bca8b744..a90faeb6e80f3 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/timelines.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/timelines.ts @@ -59,3 +59,5 @@ export const TIMELINES_OVERVIEW_ONLY_FAVORITES = `${TIMELINES_OVERVIEW} [data-te export const TIMELINES_OVERVIEW_SEARCH = `${TIMELINES_OVERVIEW} [data-test-subj="search-bar"]`; export const TIMELINES_OVERVIEW_TABLE = `${TIMELINES_OVERVIEW} [data-test-subj="timelines-table"]`; + +export const ROWS = '.euiTableRow'; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts b/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts index 09cdb6158073f..ddf432a860922 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts @@ -55,6 +55,7 @@ import { OPEN_TIMELINE_TEMPLATE_ICON, TIMELINE_SAVE_MODAL, TIMELINE_EDIT_MODAL_SAVE_BUTTON, + TIMELINE_EDIT_MODAL_SAVE_AS_NEW_SWITCH, TIMELINE_PROGRESS_BAR, QUERY_TAB_BUTTON, CLOSE_OPEN_TIMELINE_MODAL_BTN, @@ -122,6 +123,17 @@ export const addNameToTimelineAndSave = (name: string) => { cy.get(TIMELINE_TITLE_INPUT).should('not.exist'); }; +export const addNameToTimelineAndSaveAsNew = (name: string) => { + cy.get(SAVE_TIMELINE_ACTION_BTN).first().click(); + cy.get(TIMELINE_TITLE_INPUT).should('not.be.disabled').clear(); + cy.get(TIMELINE_TITLE_INPUT).type(`${name}{enter}`); + cy.get(TIMELINE_TITLE_INPUT).should('have.attr', 'value', name); + cy.get(TIMELINE_EDIT_MODAL_SAVE_AS_NEW_SWITCH).should('exist'); + cy.get(TIMELINE_EDIT_MODAL_SAVE_AS_NEW_SWITCH).click(); + cy.get(TIMELINE_EDIT_MODAL_SAVE_BUTTON).click(); + cy.get(TIMELINE_TITLE_INPUT).should('not.exist'); +}; + export const addNameAndDescriptionToTimeline = ( timeline: Timeline, modalAlreadyOpen: boolean = false From 508e9dab3604b0ea40914efb289198eeef6e997c Mon Sep 17 00:00:00 2001 From: Vidhi Rambhia <39163240+VidhiRambhia@users.noreply.github.com> Date: Thu, 30 Nov 2023 08:56:52 -0600 Subject: [PATCH 2/6] Updating PR template to add a checklist item (#171813) **Related to:** https://github.com/elastic/kibana/issues/161505 ## Summary This PR adds a checklist item in the pull request template for this repository. The added checklist item is to check if the [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on tests changed in the pull request. ### Checklist - [X] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .github/PULL_REQUEST_TEMPLATE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5974a1ee9a58a..d07f60cf09253 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -10,6 +10,7 @@ Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios +- [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) From 4ab42396bcd95696f572734ea9c3310296e60511 Mon Sep 17 00:00:00 2001 From: Adam Demjen Date: Thu, 30 Nov 2023 10:54:44 -0500 Subject: [PATCH 3/6] [Enterprise Search] Add model management API logic (#172120) ## Summary Adding parts of ML model management API logic: - Fetch models - Cached and pollable wrapper for model fetching - Create model - Start model These API logic pieces map to existing API endpoints and are currently unused. Their purpose is to enable one-click deployment of models within pipeline configuration. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../cached_fetch_models_api_logic.test.ts | 229 ++++++++++++++++++ .../cached_fetch_models_api_logic.ts | 125 ++++++++++ .../ml_models/create_model_api_logic.test.ts | 30 +++ .../api/ml_models/create_model_api_logic.ts | 28 +++ .../ml_models/fetch_models_api_logic.test.ts | 30 +++ .../api/ml_models/fetch_models_api_logic.ts | 23 ++ .../ml_models/start_model_api_logic.test.ts | 32 +++ .../api/ml_models/start_model_api_logic.ts | 28 +++ 8 files changed, 525 insertions(+) create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/cached_fetch_models_api_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/cached_fetch_models_api_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/create_model_api_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/create_model_api_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/fetch_models_api_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/fetch_models_api_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/start_model_api_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/start_model_api_logic.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/cached_fetch_models_api_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/cached_fetch_models_api_logic.test.ts new file mode 100644 index 0000000000000..6d66ed5704721 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/cached_fetch_models_api_logic.test.ts @@ -0,0 +1,229 @@ +/* + * 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 { LogicMounter } from '../../../__mocks__/kea_logic'; + +import { HttpError, Status } from '../../../../../common/types/api'; +import { MlModelDeploymentState } from '../../../../../common/types/ml'; + +import { MlModel } from '../../../../../common/types/ml'; + +import { + CachedFetchModelsApiLogic, + CachedFetchModelsApiLogicValues, +} from './cached_fetch_models_api_logic'; +import { FetchModelsApiLogic } from './fetch_models_api_logic'; + +const DEFAULT_VALUES: CachedFetchModelsApiLogicValues = { + data: [], + isInitialLoading: false, + isLoading: false, + modelsData: null, + pollTimeoutId: null, + status: Status.IDLE, +}; + +const FETCH_MODELS_API_DATA_RESPONSE: MlModel[] = [ + { + modelId: 'model_1', + title: 'Model 1', + type: 'ner', + deploymentState: MlModelDeploymentState.NotDeployed, + startTime: 0, + targetAllocationCount: 0, + nodeAllocationCount: 0, + threadsPerAllocation: 0, + isPlaceholder: false, + hasStats: false, + }, +]; +const FETCH_MODELS_API_ERROR_RESPONSE = { + body: { + error: 'Error while fetching models', + message: 'Error while fetching models', + statusCode: 500, + }, +} as HttpError; + +jest.useFakeTimers(); + +describe('TextExpansionCalloutLogic', () => { + const { mount } = new LogicMounter(CachedFetchModelsApiLogic); + const { mount: mountFetchModelsApiLogic } = new LogicMounter(FetchModelsApiLogic); + + beforeEach(() => { + jest.clearAllMocks(); + mountFetchModelsApiLogic(); + mount(); + }); + + describe('listeners', () => { + describe('apiError', () => { + it('sets new polling timeout if a timeout ID is already set', () => { + mount({ + ...DEFAULT_VALUES, + pollTimeoutId: 'timeout-id', + }); + + jest.spyOn(CachedFetchModelsApiLogic.actions, 'createPollTimeout'); + + CachedFetchModelsApiLogic.actions.apiError(FETCH_MODELS_API_ERROR_RESPONSE); + + expect(CachedFetchModelsApiLogic.actions.createPollTimeout).toHaveBeenCalled(); + }); + }); + + describe('apiSuccess', () => { + it('sets new polling timeout if a timeout ID is already set', () => { + mount({ + ...DEFAULT_VALUES, + pollTimeoutId: 'timeout-id', + }); + + jest.spyOn(CachedFetchModelsApiLogic.actions, 'createPollTimeout'); + + CachedFetchModelsApiLogic.actions.apiSuccess(FETCH_MODELS_API_DATA_RESPONSE); + + expect(CachedFetchModelsApiLogic.actions.createPollTimeout).toHaveBeenCalled(); + }); + }); + + describe('createPollTimeout', () => { + const duration = 5000; + it('clears polling timeout if it is set', () => { + mount({ + ...DEFAULT_VALUES, + pollTimeoutId: 'timeout-id', + }); + + jest.spyOn(global, 'clearTimeout'); + + CachedFetchModelsApiLogic.actions.createPollTimeout(duration); + + expect(clearTimeout).toHaveBeenCalledWith('timeout-id'); + }); + it('sets polling timeout', () => { + jest.spyOn(global, 'setTimeout'); + jest.spyOn(CachedFetchModelsApiLogic.actions, 'setTimeoutId'); + + CachedFetchModelsApiLogic.actions.createPollTimeout(duration); + + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), duration); + expect(CachedFetchModelsApiLogic.actions.setTimeoutId).toHaveBeenCalled(); + }); + }); + + describe('startPolling', () => { + it('clears polling timeout if it is set', () => { + mount({ + ...DEFAULT_VALUES, + pollTimeoutId: 'timeout-id', + }); + + jest.spyOn(global, 'clearTimeout'); + + CachedFetchModelsApiLogic.actions.startPolling(); + + expect(clearTimeout).toHaveBeenCalledWith('timeout-id'); + }); + it('makes API request and sets polling timeout', () => { + jest.spyOn(CachedFetchModelsApiLogic.actions, 'makeRequest'); + jest.spyOn(CachedFetchModelsApiLogic.actions, 'createPollTimeout'); + + CachedFetchModelsApiLogic.actions.startPolling(); + + expect(CachedFetchModelsApiLogic.actions.makeRequest).toHaveBeenCalled(); + expect(CachedFetchModelsApiLogic.actions.createPollTimeout).toHaveBeenCalled(); + }); + }); + + describe('stopPolling', () => { + it('clears polling timeout if it is set', () => { + mount({ + ...DEFAULT_VALUES, + pollTimeoutId: 'timeout-id', + }); + + jest.spyOn(global, 'clearTimeout'); + + CachedFetchModelsApiLogic.actions.stopPolling(); + + expect(clearTimeout).toHaveBeenCalledWith('timeout-id'); + }); + it('clears polling timeout value', () => { + jest.spyOn(CachedFetchModelsApiLogic.actions, 'clearPollTimeout'); + + CachedFetchModelsApiLogic.actions.stopPolling(); + + expect(CachedFetchModelsApiLogic.actions.clearPollTimeout).toHaveBeenCalled(); + }); + }); + }); + + describe('reducers', () => { + describe('modelsData', () => { + it('gets cleared on API reset', () => { + mount({ + ...DEFAULT_VALUES, + modelsData: [], + }); + + CachedFetchModelsApiLogic.actions.apiReset(); + + expect(CachedFetchModelsApiLogic.values.modelsData).toBe(null); + }); + it('gets set on API success', () => { + CachedFetchModelsApiLogic.actions.apiSuccess(FETCH_MODELS_API_DATA_RESPONSE); + + expect(CachedFetchModelsApiLogic.values.modelsData).toEqual(FETCH_MODELS_API_DATA_RESPONSE); + }); + }); + + describe('pollTimeoutId', () => { + it('gets cleared on clear timeout action', () => { + mount({ + ...DEFAULT_VALUES, + pollTimeoutId: 'timeout-id', + }); + + CachedFetchModelsApiLogic.actions.clearPollTimeout(); + + expect(CachedFetchModelsApiLogic.values.pollTimeoutId).toBe(null); + }); + it('gets set on set timeout action', () => { + const timeout = setTimeout(() => {}, 500); + + CachedFetchModelsApiLogic.actions.setTimeoutId(timeout); + + expect(CachedFetchModelsApiLogic.values.pollTimeoutId).toEqual(timeout); + }); + }); + }); + + describe('selectors', () => { + describe('isInitialLoading', () => { + it('true if API is idle', () => { + mount(DEFAULT_VALUES); + + expect(CachedFetchModelsApiLogic.values.isInitialLoading).toBe(true); + }); + it('true if API is loading for the first time', () => { + mount({ + ...DEFAULT_VALUES, + status: Status.LOADING, + }); + + expect(CachedFetchModelsApiLogic.values.isInitialLoading).toBe(true); + }); + it('false if the API is neither idle nor loading', () => { + CachedFetchModelsApiLogic.actions.apiSuccess(FETCH_MODELS_API_DATA_RESPONSE); + + expect(CachedFetchModelsApiLogic.values.isInitialLoading).toBe(false); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/cached_fetch_models_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/cached_fetch_models_api_logic.ts new file mode 100644 index 0000000000000..d65af6ec2fcf4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/cached_fetch_models_api_logic.ts @@ -0,0 +1,125 @@ +/* + * 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 { kea, MakeLogicType } from 'kea'; + +import { isEqual } from 'lodash'; + +import { Status } from '../../../../../common/types/api'; +import { MlModel } from '../../../../../common/types/ml'; +import { Actions } from '../../../shared/api_logic/create_api_logic'; + +import { FetchModelsApiLogic, FetchModelsApiResponse } from './fetch_models_api_logic'; + +const FETCH_MODELS_POLLING_DURATION = 5000; // 5 seconds +const FETCH_MODELS_POLLING_DURATION_ON_FAILURE = 30000; // 30 seconds + +export interface CachedFetchModlesApiLogicActions { + apiError: Actions<{}, FetchModelsApiResponse>['apiError']; + apiReset: Actions<{}, FetchModelsApiResponse>['apiReset']; + apiSuccess: Actions<{}, FetchModelsApiResponse>['apiSuccess']; + clearPollTimeout(): void; + createPollTimeout(duration: number): { duration: number }; + makeRequest: Actions<{}, FetchModelsApiResponse>['makeRequest']; + setTimeoutId(id: NodeJS.Timeout): { id: NodeJS.Timeout }; + startPolling(): void; + stopPolling(): void; +} + +export interface CachedFetchModelsApiLogicValues { + data: FetchModelsApiResponse; + isInitialLoading: boolean; + isLoading: boolean; + modelsData: MlModel[] | null; + pollTimeoutId: NodeJS.Timeout | null; + status: Status; +} + +export const CachedFetchModelsApiLogic = kea< + MakeLogicType +>({ + actions: { + clearPollTimeout: true, + createPollTimeout: (duration) => ({ duration }), + setTimeoutId: (id) => ({ id }), + startPolling: true, + stopPolling: true, + }, + connect: { + actions: [FetchModelsApiLogic, ['apiSuccess', 'apiError', 'apiReset', 'makeRequest']], + values: [FetchModelsApiLogic, ['data', 'status']], + }, + events: ({ values }) => ({ + beforeUnmount: () => { + if (values.pollTimeoutId) { + clearTimeout(values.pollTimeoutId); + } + }, + }), + listeners: ({ actions, values }) => ({ + apiError: () => { + if (values.pollTimeoutId) { + actions.createPollTimeout(FETCH_MODELS_POLLING_DURATION_ON_FAILURE); + } + }, + apiSuccess: () => { + if (values.pollTimeoutId) { + actions.createPollTimeout(FETCH_MODELS_POLLING_DURATION); + } + }, + createPollTimeout: ({ duration }) => { + if (values.pollTimeoutId) { + clearTimeout(values.pollTimeoutId); + } + + const timeoutId = setTimeout(() => { + actions.makeRequest({}); + }, duration); + actions.setTimeoutId(timeoutId); + }, + startPolling: () => { + if (values.pollTimeoutId) { + clearTimeout(values.pollTimeoutId); + } + actions.makeRequest({}); + actions.createPollTimeout(FETCH_MODELS_POLLING_DURATION); + }, + stopPolling: () => { + if (values.pollTimeoutId) { + clearTimeout(values.pollTimeoutId); + } + actions.clearPollTimeout(); + }, + }), + path: ['enterprise_search', 'content', 'api', 'fetch_models_api_wrapper'], + reducers: { + modelsData: [ + null, + { + apiReset: () => null, + apiSuccess: (currentState, newState) => + isEqual(currentState, newState) ? currentState : newState, + }, + ], + pollTimeoutId: [ + null, + { + clearPollTimeout: () => null, + setTimeoutId: (_, { id }) => id, + }, + ], + }, + selectors: ({ selectors }) => ({ + isInitialLoading: [ + () => [selectors.status, selectors.modelsData], + ( + status: CachedFetchModelsApiLogicValues['status'], + modelsData: CachedFetchModelsApiLogicValues['modelsData'] + ) => status === Status.IDLE || (modelsData === null && status === Status.LOADING), + ], + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/create_model_api_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/create_model_api_logic.test.ts new file mode 100644 index 0000000000000..78ef03010ac2c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/create_model_api_logic.test.ts @@ -0,0 +1,30 @@ +/* + * 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 { mockHttpValues } from '../../../__mocks__/kea_logic'; + +import { nextTick } from '@kbn/test-jest-helpers'; + +import { createModel } from './create_model_api_logic'; + +describe('CreateModelApiLogic', () => { + const { http } = mockHttpValues; + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('createModel', () => { + it('calls correct api', async () => { + const mockResponseBody = { modelId: 'model_1', deploymentState: '' }; + http.post.mockReturnValue(Promise.resolve(mockResponseBody)); + + const result = createModel({ modelId: 'model_1' }); + await nextTick(); + expect(http.post).toHaveBeenCalledWith('/internal/enterprise_search/ml/models/model_1'); + await expect(result).resolves.toEqual(mockResponseBody); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/create_model_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/create_model_api_logic.ts new file mode 100644 index 0000000000000..6852e56cea674 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/create_model_api_logic.ts @@ -0,0 +1,28 @@ +/* + * 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 { Actions, createApiLogic } from '../../../shared/api_logic/create_api_logic'; +import { HttpLogic } from '../../../shared/http'; + +export interface CreateModelArgs { + modelId: string; +} + +export interface CreateModelResponse { + deploymentState: string; + modelId: string; +} + +export const createModel = async ({ modelId }: CreateModelArgs): Promise => { + const route = `/internal/enterprise_search/ml/models/${modelId}`; + return await HttpLogic.values.http.post(route); +}; + +export const CreateModelApiLogic = createApiLogic(['create_model_api_logic'], createModel, { + showErrorFlash: false, +}); + +export type CreateModelApiLogicActions = Actions; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/fetch_models_api_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/fetch_models_api_logic.test.ts new file mode 100644 index 0000000000000..77f2a0548fa6c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/fetch_models_api_logic.test.ts @@ -0,0 +1,30 @@ +/* + * 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 { mockHttpValues } from '../../../__mocks__/kea_logic'; + +import { nextTick } from '@kbn/test-jest-helpers'; + +import { fetchModels } from './fetch_models_api_logic'; + +describe('FetchModelsApiLogic', () => { + const { http } = mockHttpValues; + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('fetchModels', () => { + it('calls correct api', async () => { + const mockResponseBody = [{ modelId: 'model_1' }, { modelId: 'model_2' }]; + http.get.mockReturnValue(Promise.resolve(mockResponseBody)); + + const result = fetchModels(); + await nextTick(); + expect(http.get).toHaveBeenCalledWith('/internal/enterprise_search/ml/models'); + await expect(result).resolves.toEqual(mockResponseBody); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/fetch_models_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/fetch_models_api_logic.ts new file mode 100644 index 0000000000000..751a03546b059 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/fetch_models_api_logic.ts @@ -0,0 +1,23 @@ +/* + * 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 { MlModel } from '../../../../../common/types/ml'; +import { Actions, createApiLogic } from '../../../shared/api_logic/create_api_logic'; +import { HttpLogic } from '../../../shared/http'; + +export type FetchModelsApiResponse = MlModel[]; + +export const fetchModels = async () => { + const route = '/internal/enterprise_search/ml/models'; + return await HttpLogic.values.http.get(route); +}; + +export const FetchModelsApiLogic = createApiLogic(['fetch_models_api_logic'], fetchModels, { + showErrorFlash: false, +}); + +export type FetchModelsApiLogicActions = Actions<{}, FetchModelsApiResponse>; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/start_model_api_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/start_model_api_logic.test.ts new file mode 100644 index 0000000000000..0c9d9da875e52 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/start_model_api_logic.test.ts @@ -0,0 +1,32 @@ +/* + * 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 { mockHttpValues } from '../../../__mocks__/kea_logic'; + +import { nextTick } from '@kbn/test-jest-helpers'; + +import { startModel } from './start_model_api_logic'; + +describe('StartModelApiLogic', () => { + const { http } = mockHttpValues; + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('startModel', () => { + it('calls correct api', async () => { + const mockResponseBody = { modelId: 'model_1', deploymentState: 'started' }; + http.post.mockReturnValue(Promise.resolve(mockResponseBody)); + + const result = startModel({ modelId: 'model_1' }); + await nextTick(); + expect(http.post).toHaveBeenCalledWith( + '/internal/enterprise_search/ml/models/model_1/deploy' + ); + await expect(result).resolves.toEqual(mockResponseBody); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/start_model_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/start_model_api_logic.ts new file mode 100644 index 0000000000000..333b23cd65242 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/start_model_api_logic.ts @@ -0,0 +1,28 @@ +/* + * 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 { Actions, createApiLogic } from '../../../shared/api_logic/create_api_logic'; +import { HttpLogic } from '../../../shared/http'; + +export interface StartModelArgs { + modelId: string; +} + +export interface StartModelResponse { + deploymentState: string; + modelId: string; +} + +export const startModel = async ({ modelId }: StartModelArgs): Promise => { + const route = `/internal/enterprise_search/ml/models/${modelId}/deploy`; + return await HttpLogic.values.http.post(route); +}; + +export const StartModelApiLogic = createApiLogic(['start_model_api_logic'], startModel, { + showErrorFlash: false, +}); + +export type StartModelApiLogicActions = Actions; From cc80cf449e08ffa249c7bbcdec4eabeb4db01207 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Thu, 30 Nov 2023 16:30:57 +0000 Subject: [PATCH 4/6] skip flaky suite (#142774) --- .../__jest__/client_integration/follower_indices_list.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js index 536c188b48369..839aa48464bbf 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js @@ -309,7 +309,8 @@ describe('', () => { }); }); - describe('detail panel', () => { + // FLAKY: https://github.com/elastic/kibana/issues/142774 + describe.skip('detail panel', () => { test('should open a detail panel when clicking on a follower index', async () => { expect(exists('followerIndexDetail')).toBe(false); From 3e9ca7a6730c55a900a61bffa03401ecaa60d091 Mon Sep 17 00:00:00 2001 From: Paulo Henrique Date: Thu, 30 Nov 2023 08:32:49 -0800 Subject: [PATCH 5/6] [Security Solution] [Grouping] Add isLoading to groupPanelRenderer params (#172245) ## Summary This PR forwards the `isLoading` parameter that is sent to the Grouping component, to allow the consumer to customize groupPanelRenderers to leverage that property while data is loading when switching between groups. Below is a recording demoing how a UI can leverage that option. https://github.com/elastic/kibana/assets/19270322/db8f476d-00cb-48d9-bdcd-d3c242bec79c --- .../src/components/grouping.test.tsx | 49 +++++++++++++++++++ .../src/components/grouping.tsx | 2 +- .../src/components/types.ts | 3 +- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/kbn-securitysolution-grouping/src/components/grouping.test.tsx b/packages/kbn-securitysolution-grouping/src/components/grouping.test.tsx index 2b581ed774d5d..0cf16ae4c8217 100644 --- a/packages/kbn-securitysolution-grouping/src/components/grouping.test.tsx +++ b/packages/kbn-securitysolution-grouping/src/components/grouping.test.tsx @@ -136,4 +136,53 @@ describe('grouping container', () => { expect(renderChildComponent).toHaveBeenCalledWith(getNullGroupFilter('host.name')); }); + + it('Renders groupPanelRenderer when provided', () => { + const groupPanelRenderer = jest.fn(); + render( + + + + ); + + expect(groupPanelRenderer).toHaveBeenNthCalledWith( + 1, + 'host.name', + testProps.data.groupByFields.buckets[0], + undefined, + false + ); + + expect(groupPanelRenderer).toHaveBeenNthCalledWith( + 2, + 'host.name', + testProps.data.groupByFields.buckets[1], + undefined, + false + ); + + expect(groupPanelRenderer).toHaveBeenNthCalledWith( + 3, + 'host.name', + testProps.data.groupByFields.buckets[2], + 'The selected group by field, host.name, is missing a value for this group of events.', + false + ); + }); + it('Renders groupPanelRenderer when provided with isLoading attribute', () => { + const groupPanelRenderer = jest.fn(); + render( + + + + ); + + expect(groupPanelRenderer).toHaveBeenNthCalledWith( + 1, + 'host.name', + testProps.data.groupByFields.buckets[0], + undefined, + true + ); + }); }); diff --git a/packages/kbn-securitysolution-grouping/src/components/grouping.tsx b/packages/kbn-securitysolution-grouping/src/components/grouping.tsx index aab42a0804e4c..5ae1037d9edb3 100644 --- a/packages/kbn-securitysolution-grouping/src/components/grouping.tsx +++ b/packages/kbn-securitysolution-grouping/src/components/grouping.tsx @@ -128,7 +128,7 @@ const GroupingComponent = ({ groupBucket={groupBucket} groupPanelRenderer={ groupPanelRenderer && - groupPanelRenderer(selectedGroup, groupBucket, nullGroupMessage) + groupPanelRenderer(selectedGroup, groupBucket, nullGroupMessage, isLoading) } isLoading={isLoading} onToggleGroup={(isOpen) => { diff --git a/packages/kbn-securitysolution-grouping/src/components/types.ts b/packages/kbn-securitysolution-grouping/src/components/types.ts index 6987a09c083f8..43a9af13372f7 100644 --- a/packages/kbn-securitysolution-grouping/src/components/types.ts +++ b/packages/kbn-securitysolution-grouping/src/components/types.ts @@ -76,7 +76,8 @@ export type GroupStatsRenderer = ( export type GroupPanelRenderer = ( selectedGroup: string, fieldBucket: RawBucket, - nullGroupMessage?: string + nullGroupMessage?: string, + isLoading?: boolean ) => JSX.Element | undefined; export type OnGroupToggle = (params: { From ab5ff9ca626baa90c3cc0e92813ff70cb5956e23 Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Thu, 30 Nov 2023 12:43:39 -0400 Subject: [PATCH 6/6] [Discover/CSV Reporting] Fix support for nested field columns in CSV reports (#172240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary When we generate the parameters for the report, we add all of the selected columns as entries in the search request `fields` array (or `*` if none are selected, which is why this case works), but this doesn't work for nested fields since [the fields API doesn't support nested field roots](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-fields.html#search-fields-nested): >However, when the `fields` pattern targets the nested `user` field directly, no values will be returned because the pattern doesn’t match any leaf fields. Instead we can detect nested fields and add them to the `fields` array as `{nestedFieldName}.*`, ensuring that all of the leaf fields are returned in the response. Fixes #172236. ### Checklist - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../public/utils/get_sharing_data.test.ts | 34 ++++++++++++++++++- .../discover/public/utils/get_sharing_data.ts | 13 ++++++- .../get_csv_panel_action.test.ts | 3 +- x-pack/plugins/reporting/tsconfig.json | 1 + 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/plugins/discover/public/utils/get_sharing_data.test.ts b/src/plugins/discover/public/utils/get_sharing_data.test.ts index 96012361a706e..a0c7581fd9419 100644 --- a/src/plugins/discover/public/utils/get_sharing_data.test.ts +++ b/src/plugins/discover/public/utils/get_sharing_data.test.ts @@ -16,7 +16,7 @@ import { SORT_DEFAULT_ORDER_SETTING, SEARCH_FIELDS_FROM_SOURCE, } from '@kbn/discover-utils'; -import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; +import { buildDataViewMock, dataViewMock } from '@kbn/discover-utils/src/__mocks__'; import { getSharingData, showPublicUrlSwitch } from './get_sharing_data'; describe('getSharingData', () => { @@ -162,6 +162,38 @@ describe('getSharingData', () => { ]); }); + test('getSearchSource supports nested fields', async () => { + const index = buildDataViewMock({ + name: 'the-data-view', + timeFieldName: 'cool-timefield', + fields: [ + ...dataViewMock.fields, + { + name: 'cool-field-2.field', + type: 'keyword', + subType: { + nested: { + path: 'cool-field-2.field.path', + }, + }, + }, + ] as DataView['fields'], + }); + const searchSourceMock = createSearchSourceMock({ index }); + const { getSearchSource } = await getSharingData( + searchSourceMock, + { + columns: ['cool-field-1', 'cool-field-2'], + }, + services + ); + expect(getSearchSource({}).fields).toStrictEqual([ + { field: 'cool-timefield', include_unmapped: 'true' }, + { field: 'cool-field-1', include_unmapped: 'true' }, + { field: 'cool-field-2.*', include_unmapped: 'true' }, + ]); + }); + test('fields have prepended timeField', async () => { const index = { ...dataViewMock } as DataView; index.timeFieldName = 'cool-timefield'; diff --git a/src/plugins/discover/public/utils/get_sharing_data.ts b/src/plugins/discover/public/utils/get_sharing_data.ts index 0e243385272c4..9e3b2b2369469 100644 --- a/src/plugins/discover/public/utils/get_sharing_data.ts +++ b/src/plugins/discover/public/utils/get_sharing_data.ts @@ -17,6 +17,7 @@ import type { Filter } from '@kbn/es-query'; import type { SavedSearch, SortOrder } from '@kbn/saved-search-plugin/public'; import { DOC_HIDE_TIME_COLUMN_SETTING, + isNestedFieldParent, SEARCH_FIELDS_FROM_SOURCE, SORT_DEFAULT_ORDER_SETTING, } from '@kbn/discover-utils'; @@ -113,7 +114,17 @@ export async function getSharingData( if (useFieldsApi) { searchSource.removeField('fieldsFromSource'); const fields = columns.length - ? columns.map((field) => ({ field, include_unmapped: 'true' })) + ? columns.map((column) => { + let field = column; + + // If this column is a nested field, add a wildcard to the field name in order to fetch + // all leaf fields for the report, since the fields API doesn't support nested field roots + if (isNestedFieldParent(column, index)) { + field = `${column}.*`; + } + + return { field, include_unmapped: 'true' }; + }) : [{ field: '*', include_unmapped: 'true' }]; searchSource.setField('fields', fields); diff --git a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts index 5bf73525c2dcc..bf868199de4ce 100644 --- a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts +++ b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts @@ -18,6 +18,7 @@ import { ReportingAPIClient } from '../lib/reporting_api_client'; import type { ReportingPublicPluginStartDependencies } from '../plugin'; import type { ActionContext } from './get_csv_panel_action'; import { ReportingCsvPanelAction } from './get_csv_panel_action'; +import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; const core = coreMock.createSetup(); let apiClient: ReportingAPIClient; @@ -124,7 +125,7 @@ describe('GetCsvReportPanelAction', () => { createCopy: () => mockSearchSource, removeField: jest.fn(), setField: jest.fn(), - getField: jest.fn(), + getField: jest.fn((name) => (name === 'index' ? dataViewMock : undefined)), getSerializedFields: jest.fn().mockImplementation(() => ({ testData: 'testDataValue' })), } as unknown as SearchSource; context.embeddable.getSavedSearch = () => { diff --git a/x-pack/plugins/reporting/tsconfig.json b/x-pack/plugins/reporting/tsconfig.json index 53a4ab34eb197..4b784e29db102 100644 --- a/x-pack/plugins/reporting/tsconfig.json +++ b/x-pack/plugins/reporting/tsconfig.json @@ -50,6 +50,7 @@ "@kbn/reporting-mocks-server", "@kbn/core-http-request-handler-context-server", "@kbn/reporting-public", + "@kbn/discover-utils", ], "exclude": [ "target/**/*",