diff --git a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts index 5de8a0291b518..96966c9a9c13e 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts @@ -267,6 +267,9 @@ export const allowedExperimentalValues = Object.freeze({ * Enables banner for informing users about changes in data collection. */ eventCollectionDataReductionBannerEnabled: false, + + /** Enables new Data View Picker */ + newDataViewPickerEnabled: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/solutions/security/plugins/security_solution/public/app/home/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/app/home/index.tsx index fe9cad4701ae9..0da4a459352ea 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/app/home/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/app/home/index.tsx @@ -26,6 +26,7 @@ import { useSetupDetectionEngineHealthApi } from '../../detection_engine/rule_mo import { TopValuesPopover } from '../components/top_values_popover/top_values_popover'; import { AssistantOverlay } from '../../assistant/overlay'; import { useInitSourcerer } from '../../sourcerer/containers/use_init_sourcerer'; +import { useInitDataViewManager } from '../../data_view_manager/hooks/use_init_data_view_manager'; interface HomePageProps { children: React.ReactNode; @@ -37,6 +38,7 @@ const HomePageComponent: React.FC = ({ children }) => { useUrlState(); useUpdateBrowserTitle(); useUpdateExecutionContext(); + useInitDataViewManager(); // side effect: this will attempt to upgrade the endpoint package if it is not up to date // this will run when a user navigates to the Security Solution app and when they navigate between diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/with_data_view/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/with_data_view/index.test.tsx index 9313f20784474..9c0bb2b35c084 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/with_data_view/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/with_data_view/index.test.tsx @@ -9,6 +9,7 @@ import type { DataView } from '@kbn/data-views-plugin/common'; import { render, screen } from '@testing-library/react'; import { withDataView } from '.'; import { useGetScopedSourcererDataView } from '../../../sourcerer/components/use_get_sourcerer_data_view'; +import { TestProviders } from '../../mock'; interface TestComponentProps { dataView: DataView; @@ -39,18 +40,18 @@ describe('withDataViewId', () => { }); it('should render default error components when there is not fallback provided and dataViewId is null', async () => { const RenderedComponent = withDataView(TestComponent); - render(); + render(, { wrapper: TestProviders }); expect(screen.getByTestId(TEST_ID.DATA_VIEW_ERROR_COMPONENT)).toBeVisible(); }); it('should render provided fallback and dataViewId is null', async () => { const RenderedComponent = withDataView(TestComponent, ); - render(); + render(, { wrapper: TestProviders }); expect(screen.getByTestId(TEST_ID.FALLBACK_COMPONENT)).toBeVisible(); }); it('should render provided component when dataViewId is not null', async () => { (useGetScopedSourcererDataView as jest.Mock).mockReturnValue({ id: 'test' }); const RenderedComponent = withDataView(TestComponent); - render(); + render(, { wrapper: TestProviders }); expect(screen.getByTestId(TEST_ID.TEST_COMPONENT)).toBeVisible(); expect(dataViewMockFn).toHaveBeenCalledWith({ id: 'test' }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/with_data_view/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/with_data_view/index.tsx index 41d3b451a61e6..150a5a9eb36dd 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/with_data_view/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/with_data_view/index.tsx @@ -9,7 +9,11 @@ import React from 'react'; import type { ComponentType } from 'react'; import type { ReactElement } from 'react-markdown'; import type { DataView } from '@kbn/data-views-plugin/common'; +import { DataViewManagerScopeName } from '../../../data_view_manager/constants'; +import { useFullDataView } from '../../../data_view_manager/hooks/use_full_data_view'; import { DataViewErrorComponent } from './data_view_error'; +import { useEnableExperimental } from '../../hooks/use_experimental_features'; + import { useGetScopedSourcererDataView } from '../../../sourcerer/components/use_get_sourcerer_data_view'; import { SourcererScopeName } from '../../../sourcerer/store/model'; @@ -30,10 +34,17 @@ export const withDataView =

( fallback?: ReactElement ) => { const ComponentWithDataView = (props: OmitDataView

) => { - const dataView = useGetScopedSourcererDataView({ + const experimentalDataView = useFullDataView(DataViewManagerScopeName.timeline); + + let dataView = useGetScopedSourcererDataView({ sourcererScope: SourcererScopeName.timeline, }); + const { newDataViewPickerEnabled } = useEnableExperimental(); + if (newDataViewPickerEnabled) { + dataView = experimentalDataView; + } + if (!dataView) { return fallback ?? ; } diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/__mocks__/use_experimental_features.ts b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/__mocks__/use_experimental_features.ts index 2084685c9b6a0..d7f9efc28429d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/__mocks__/use_experimental_features.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/__mocks__/use_experimental_features.ts @@ -17,3 +17,5 @@ export const useIsExperimentalFeatureEnabled = jest throw new Error(`Invalid experimental value ${feature}}`); }); + +export const useEnableExperimental = jest.fn(() => ({ newDataViewPickerEnabled: false })); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/timeline/use_investigate_in_timeline.ts b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/timeline/use_investigate_in_timeline.ts index c1c195b0c5965..b73a69ab2bfa3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/timeline/use_investigate_in_timeline.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/timeline/use_investigate_in_timeline.ts @@ -8,17 +8,19 @@ import { useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import type { Filter, Query } from '@kbn/es-query'; +import { useSelectDataView } from '../../../data_view_manager/hooks/use_select_data_view'; import { useCreateTimeline } from '../../../timelines/hooks/use_create_timeline'; import { updateProviders, setFilters, applyKqlFilterQuery } from '../../../timelines/store/actions'; import { SourcererScopeName } from '../../../sourcerer/store/model'; import type { DataProvider } from '../../../../common/types'; import { sourcererSelectors } from '../../store'; -import { sourcererActions } from '../../store/actions'; import { inputsActions } from '../../store/inputs'; import { InputsModelId } from '../../store/inputs/constants'; import type { TimeRange } from '../../store/inputs/model'; import { TimelineId } from '../../../../common/types/timeline'; import { TimelineTypeEnum } from '../../../../common/api/timeline'; +import { sourcererActions } from '../../store/actions'; +import { useEnableExperimental } from '../use_experimental_features'; interface InvestigateInTimelineArgs { /** @@ -57,6 +59,7 @@ export const useInvestigateInTimeline = () => { const signalIndexName = useSelector(sourcererSelectors.signalIndexName); const defaultDataView = useSelector(sourcererSelectors.defaultDataView); + const { newDataViewPickerEnabled } = useEnableExperimental(); const clearTimelineTemplate = useCreateTimeline({ timelineId: TimelineId.active, @@ -68,6 +71,8 @@ export const useInvestigateInTimeline = () => { timelineType: TimelineTypeEnum.default, }); + const setSelectedDataView = useSelectDataView(); + const investigateInTimeline = useCallback( async ({ query, @@ -124,19 +129,35 @@ export const useInvestigateInTimeline = () => { // Only show detection alerts // (This is required so the timeline event count matches the prevalence count) if (!keepDataView) { - dispatch( - sourcererActions.setSelectedDataView({ - id: SourcererScopeName.timeline, - selectedDataViewId: defaultDataView.id, - selectedPatterns: [signalIndexName || ''], - }) - ); + if (newDataViewPickerEnabled) { + setSelectedDataView({ + scope: [SourcererScopeName.timeline], + id: defaultDataView.id, + fallbackPatterns: [signalIndexName || ''], + }); + } else { + dispatch( + sourcererActions.setSelectedDataView({ + id: SourcererScopeName.timeline, + selectedDataViewId: defaultDataView.id, + selectedPatterns: [signalIndexName || ''], + }) + ); + } } // Unlock the time range from the global time range dispatch(inputsActions.removeLinkTo([InputsModelId.timeline, InputsModelId.global])); } }, - [clearTimelineTemplate, clearTimelineDefault, dispatch, defaultDataView.id, signalIndexName] + [ + clearTimelineTemplate, + clearTimelineDefault, + dispatch, + newDataViewPickerEnabled, + setSelectedDataView, + defaultDataView.id, + signalIndexName, + ] ); return { investigateInTimeline }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/solutions/security/plugins/security_solution/public/common/mock/global_state.ts index 79bd0eb558683..ad9369b57f5a7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/mock/global_state.ts @@ -48,6 +48,7 @@ import { initialGroupingState } from '../store/grouping/reducer'; import type { SourcererState } from '../../sourcerer/store'; import { EMPTY_RESOLVER } from '../../resolver/store/helpers'; import { getMockDiscoverInTimelineState } from './mock_discover_state'; +import { mockDataViewManagerState } from '../../data_view_manager/redux/mock'; const mockFieldMap: DataViewSpec['fields'] = Object.fromEntries( mockIndexFields.map((field) => [field.name, field]) @@ -554,4 +555,5 @@ export const mockGlobalState: State = { selectedIds: [], pendingDeleteIds: [], }, + ...mockDataViewManagerState, }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/store/middlewares.ts b/x-pack/solutions/security/plugins/security_solution/public/common/store/middlewares.ts index 76c290a3c895c..fe7a2b0adf670 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/store/middlewares.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/store/middlewares.ts @@ -7,13 +7,17 @@ import type { CoreStart } from '@kbn/core/public'; import type { Storage } from '@kbn/kibana-utils-plugin/public'; +import { createListenerMiddleware } from '@reduxjs/toolkit'; import { createTimelineMiddlewares } from '../../timelines/store/middlewares/create_timeline_middlewares'; import { dataTableLocalStorageMiddleware } from './data_table/middleware_local_storage'; import { userAssetTableLocalStorageMiddleware } from '../../explore/users/store/middleware_storage'; +const listenerMiddleware = createListenerMiddleware(); + export function createMiddlewares(kibana: CoreStart, storage: Storage) { return [ + listenerMiddleware.middleware, dataTableLocalStorageMiddleware(storage), userAssetTableLocalStorageMiddleware(storage), ...createTimelineMiddlewares(kibana), diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/store/reducer.ts b/x-pack/solutions/security/plugins/security_solution/public/common/store/reducer.ts index ea684909a8776..540d943e96658 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/store/reducer.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/store/reducer.ts @@ -37,6 +37,10 @@ import { securitySolutionDiscoverReducer } from './discover/reducer'; import type { AnalyzerState } from '../../resolver/types'; import type { NotesState } from '../../notes/store/notes.slice'; import { notesReducer } from '../../notes/store/notes.slice'; +import { + dataViewManagerReducer, + initialDataViewManagerState, +} from '../../data_view_manager/redux/reducer'; enableMapSet(); @@ -132,6 +136,7 @@ export const createInitialState = ( savedSearch: undefined, }, notes: notesState, + dataViewManager: initialDataViewManagerState.dataViewManager, }; return preloadedState; @@ -155,4 +160,5 @@ export const createReducer: ( discover: securitySolutionDiscoverReducer, ...pluginsReducer, notes: notesReducer, + dataViewManager: dataViewManagerReducer, }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/store/types.ts b/x-pack/solutions/security/plugins/security_solution/public/common/store/types.ts index bf83f9146bdb2..d3cae8899aa35 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/store/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/store/types.ts @@ -26,6 +26,7 @@ import type { GroupState } from './grouping/types'; import type { SecuritySolutionDiscoverState } from './discover/model'; import type { AnalyzerState } from '../../resolver/types'; import type { NotesState } from '../../notes/store/notes.slice'; +import type { RootState as DataViewManagerState } from '../../data_view_manager/redux/reducer'; export type State = HostsPluginState & UsersPluginState & @@ -40,7 +41,7 @@ export type State = HostsPluginState & discover: SecuritySolutionDiscoverState; } & DataTableState & GroupState & - AnalyzerState & { notes: NotesState }; + AnalyzerState & { notes: NotesState } & DataViewManagerState; /** * The Redux store type for the Security app. */ diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx index 9bc192880ea89..ce3f28c35b63e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx @@ -13,8 +13,11 @@ import { appLinks } from '../../../app_links'; import { useUserPrivileges } from '../../components/user_privileges'; import { useShowTimeline } from './use_show_timeline'; import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks'; +import { TestProviders } from '../../mock'; +import { hasAccessToSecuritySolution } from '../../../helpers_access'; jest.mock('../../components/user_privileges'); +jest.mock('../../../helpers_access', () => ({ hasAccessToSecuritySolution: jest.fn(() => true) })); const mockUseLocation = jest.fn().mockReturnValue({ pathname: '/overview' }); jest.mock('react-router-dom', () => { @@ -35,30 +38,11 @@ jest.mock('../../../sourcerer/containers', () => ({ useSourcererDataView: () => mockUseSourcererDataView(), })); -const mockSiemUserCanRead = jest.fn(() => true); -jest.mock('../../lib/kibana', () => { - const original = jest.requireActual('../../lib/kibana'); - - return { - ...original, - useKibana: () => ({ - services: { - ...original.useKibana().services, - application: { - capabilities: { - siemV2: { - show: mockSiemUserCanRead(), - }, - }, - }, - }, - }), - }; -}); - const mockUpselling = new UpsellingService(); const mockUiSettingsClient = uiSettingsServiceMock.createStartContract(); +const renderUseShowTimeline = () => renderHook(() => useShowTimeline(), { wrapper: TestProviders }); + describe('use show timeline', () => { beforeAll(() => { (useUserPrivileges as unknown as jest.Mock).mockReturnValue({ @@ -84,25 +68,25 @@ describe('use show timeline', () => { }); it('shows timeline for routes on default', async () => { - const { result } = renderHook(() => useShowTimeline()); + const { result } = renderUseShowTimeline(); await waitFor(() => expect(result.current).toEqual([true])); }); it('hides timeline for blacklist routes', async () => { mockUseLocation.mockReturnValueOnce({ pathname: '/rules/add_rules' }); - const { result } = renderHook(() => useShowTimeline()); + const { result } = renderUseShowTimeline(); await waitFor(() => expect(result.current).toEqual([false])); }); it('shows timeline for partial blacklist routes', async () => { mockUseLocation.mockReturnValueOnce({ pathname: '/rules' }); - const { result } = renderHook(() => useShowTimeline()); + const { result } = renderUseShowTimeline(); await waitFor(() => expect(result.current).toEqual([true])); }); it('hides timeline for sub blacklist routes', async () => { mockUseLocation.mockReturnValueOnce({ pathname: '/administration/policy' }); - const { result } = renderHook(() => useShowTimeline()); + const { result } = renderUseShowTimeline(); await waitFor(() => expect(result.current).toEqual([false])); }); it('hides timeline for users without timeline access', async () => { @@ -110,7 +94,7 @@ describe('use show timeline', () => { timelinePrivileges: { read: false }, }); - const { result } = renderHook(() => useShowTimeline()); + const { result } = renderUseShowTimeline(); const showTimeline = result.current; expect(showTimeline).toEqual([false]); }); @@ -120,7 +104,7 @@ it('shows timeline for users with timeline read access', async () => { timelinePrivileges: { read: true }, }); - const { result } = renderHook(() => useShowTimeline()); + const { result } = renderUseShowTimeline(); const showTimeline = result.current; expect(showTimeline).toEqual([true]); }); @@ -128,33 +112,32 @@ it('shows timeline for users with timeline read access', async () => { describe('sourcererDataView', () => { it('should show timeline when indices exist', () => { mockUseSourcererDataView.mockReturnValueOnce({ indicesExist: true, dataViewId: 'test' }); - const { result } = renderHook(() => useShowTimeline()); + const { result } = renderUseShowTimeline(); expect(result.current).toEqual([true]); }); it('should show timeline when dataViewId is null', () => { mockUseSourcererDataView.mockReturnValueOnce({ indicesExist: false, dataViewId: null }); - const { result } = renderHook(() => useShowTimeline()); + const { result } = renderUseShowTimeline(); expect(result.current).toEqual([true]); }); it('should not show timeline when dataViewId is not null and indices does not exist', () => { mockUseSourcererDataView.mockReturnValueOnce({ indicesExist: false, dataViewId: 'test' }); - const { result } = renderHook(() => useShowTimeline()); + const { result } = renderUseShowTimeline(); expect(result.current).toEqual([false]); }); }); describe('Security solution capabilities', () => { it('should show timeline when user has read capabilities', () => { - mockSiemUserCanRead.mockReturnValueOnce(true); - const { result } = renderHook(() => useShowTimeline()); + const { result } = renderUseShowTimeline(); expect(result.current).toEqual([true]); }); it('should not show timeline when user does not have read capabilities', () => { - mockSiemUserCanRead.mockReturnValueOnce(false); - const { result } = renderHook(() => useShowTimeline()); + jest.mocked(hasAccessToSecuritySolution).mockReturnValueOnce(false); + const { result } = renderUseShowTimeline(); expect(result.current).toEqual([false]); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/utils/timeline/use_show_timeline_for_path.ts b/x-pack/solutions/security/plugins/security_solution/public/common/utils/timeline/use_show_timeline_for_path.ts index 30f5b078770b5..a79ef12958b94 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/utils/timeline/use_show_timeline_for_path.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/utils/timeline/use_show_timeline_for_path.ts @@ -9,11 +9,13 @@ import { useCallback, useMemo } from 'react'; import { matchPath } from 'react-router-dom'; import { getLinksWithHiddenTimeline } from '../../links'; -import { SourcererScopeName } from '../../../sourcerer/store/model'; -import { useSourcererDataView } from '../../../sourcerer/containers'; import { useKibana } from '../../lib/kibana'; import { hasAccessToSecuritySolution } from '../../../helpers_access'; +import { SourcererScopeName } from '../../../sourcerer/store/model'; +import { useSourcererDataView } from '../../../sourcerer/containers'; +import { useEnableExperimental } from '../../hooks/use_experimental_features'; + const isTimelinePathVisible = (currentPath: string): boolean => { const groupLinksWithHiddenTimelinePaths = getLinksWithHiddenTimeline().map((l) => l.path); const hiddenTimelineRoutes = groupLinksWithHiddenTimelinePaths; @@ -21,7 +23,6 @@ const isTimelinePathVisible = (currentPath: string): boolean => { }; export const useShowTimelineForGivenPath = () => { - const { indicesExist, dataViewId } = useSourcererDataView(SourcererScopeName.timeline); const { services: { application: { capabilities }, @@ -29,10 +30,18 @@ export const useShowTimelineForGivenPath = () => { } = useKibana(); const userHasSecuritySolutionVisible = hasAccessToSecuritySolution(capabilities); - const isTimelineAllowed = useMemo( - () => userHasSecuritySolutionVisible && (indicesExist || dataViewId === null), - [indicesExist, dataViewId, userHasSecuritySolutionVisible] - ); + const { indicesExist, dataViewId } = useSourcererDataView(SourcererScopeName.timeline); + + const { newDataViewPickerEnabled } = useEnableExperimental(); + + const isTimelineAllowed = useMemo(() => { + // NOTE: with new Data View Picker, data view is always defined + if (newDataViewPickerEnabled) { + return userHasSecuritySolutionVisible; + } + + return userHasSecuritySolutionVisible && (indicesExist || dataViewId === null); + }, [newDataViewPickerEnabled, userHasSecuritySolutionVisible, indicesExist, dataViewId]); const getIsTimelineVisible = useCallback( (pathname: string) => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.test.tsx new file mode 100644 index 0000000000000..8faa073a7ca07 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.test.tsx @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { DataViewPicker } from '.'; +import { useDataView } from '../../hooks/use_data_view'; +import { DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, DataViewManagerScopeName } from '../../constants'; +import { sharedDataViewManagerSlice } from '../../redux/slices'; +import { useDispatch } from 'react-redux'; +import { useKibana } from '../../../common/lib/kibana'; +import { DataView } from '@kbn/data-views-plugin/common'; +import { FieldFormatsRegistry } from '@kbn/field-formats-plugin/common'; +import { TestProviders } from '../../../common/mock/test_providers'; +import { useSelectDataView } from '../../hooks/use_select_data_view'; + +jest.mock('../../hooks/use_data_view', () => ({ + useDataView: jest.fn(), +})); + +jest.mock('../../hooks/use_select_data_view', () => ({ + useSelectDataView: jest.fn().mockReturnValue(jest.fn()), +})); + +jest.mock('react-redux', () => { + return { + ...jest.requireActual('react-redux'), + useDispatch: jest.fn(), + }; +}); + +jest.mock('../../../common/lib/kibana', () => ({ + useKibana: jest.fn(), +})); + +jest.mock('@kbn/unified-search-plugin/public', () => ({ + ...jest.requireActual('@kbn/unified-search-plugin/public'), + DataViewPicker: jest.fn((props) => ( +

+ + + {props.onAddField && ( + + )} +
{props.currentDataViewId}
+
{props.trigger.label}
+
+ )), +})); + +describe('DataViewPicker', () => { + let mockDispatch = jest.fn(); + + beforeEach(() => { + jest.mocked(useDataView).mockReturnValue({ + dataView: { + id: DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, + name: 'Default Security Data View', + }, + status: 'ready', + }); + + mockDispatch = jest.fn(); + + jest.mocked(useDispatch).mockReturnValue(mockDispatch); + + jest.mocked(useKibana).mockReturnValue({ + services: { + dataViewFieldEditor: { openEditor: jest.fn() }, + dataViewEditor: { openEditor: jest.fn() }, + data: { dataViews: { get: jest.fn() } }, + }, + } as unknown as ReturnType); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders with the current data view ID', () => { + render( + + + + ); + + expect(screen.getByTestId('currentDataViewId')).toHaveTextContent('security-solution-default'); + expect(screen.getByTestId('trigger')).toHaveTextContent('Default Security Data View'); + }); + + it('calls selectDataView when changing data view', () => { + render( + + + + ); + + fireEvent.click(screen.getByTestId('changeDataView')); + + expect(jest.mocked(useSelectDataView())).toHaveBeenCalledWith({ + id: 'new-data-view-id', + scope: ['default'], + }); + }); + + it('opens data view editor when creating a new data view', async () => { + render( + + + + ); + + fireEvent.click(screen.getByTestId('createDataView')); + + expect(jest.mocked(useKibana().services.dataViewEditor.openEditor)).toHaveBeenCalled(); + + // Test the onSave callback + const onSaveCallback = jest.mocked(useKibana().services.dataViewEditor.openEditor).mock + .calls[0][0].onSave; + + const newDataView = new DataView({ + spec: { id: 'new-data-view-id', name: 'New Data View' }, + fieldFormats: new FieldFormatsRegistry(), + }); + + onSaveCallback(newDataView); + + expect(mockDispatch).toHaveBeenCalledWith( + sharedDataViewManagerSlice.actions.addDataView(newDataView) + ); + expect(jest.mocked(useSelectDataView())).toHaveBeenCalledWith({ + id: 'new-data-view-id', + scope: ['default'], + }); + }); + + it('opens field editor when adding a field', async () => { + const mockFieldEditorClose = jest.fn(); + jest + .mocked(useKibana().services.dataViewFieldEditor.openEditor) + .mockResolvedValue(mockFieldEditorClose); + + render( + + + + ); + + fireEvent.click(screen.getByTestId('addField')); + + await waitFor(() => { + expect(jest.mocked(useKibana().services.data.dataViews.get)).toHaveBeenCalledWith( + 'security-solution-default' + ); + expect(jest.mocked(useKibana().services.dataViewFieldEditor.openEditor)).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.tsx new file mode 100644 index 0000000000000..2abae455a4e62 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/components/data_view_picker/index.tsx @@ -0,0 +1,130 @@ +/* + * 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 { DataViewPicker as UnifiedDataViewPicker } from '@kbn/unified-search-plugin/public'; +import React, { useCallback, useRef, useMemo, memo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { DataView, type DataViewListItem } from '@kbn/data-views-plugin/public'; +import type { DataViewManagerScopeName } from '../../constants'; +import { useKibana } from '../../../common/lib/kibana'; +import { DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID } from '../../constants'; +import { useDataView } from '../../hooks/use_data_view'; +import { sharedStateSelector } from '../../redux/selectors'; +import { sharedDataViewManagerSlice } from '../../redux/slices'; +import { useSelectDataView } from '../../hooks/use_select_data_view'; + +export const DataViewPicker = memo((props: { scope: DataViewManagerScopeName }) => { + const dispatch = useDispatch(); + const selectDataView = useSelectDataView(); + + const { + services: { dataViewEditor, data, dataViewFieldEditor, fieldFormats }, + } = useKibana(); + + const closeDataViewEditor = useRef<() => void | undefined>(); + const closeFieldEditor = useRef<() => void | undefined>(); + + const { dataView } = useDataView(props.scope); + + const dataViewId = dataView?.id; + + const createNewDataView = useCallback(() => { + closeDataViewEditor.current = dataViewEditor.openEditor({ + onSave: async (newDataView) => { + dispatch(sharedDataViewManagerSlice.actions.addDataView(newDataView)); + selectDataView({ id: newDataView.id, scope: [props.scope] }); + }, + allowAdHocDataView: true, + }); + }, [dataViewEditor, dispatch, props.scope, selectDataView]); + + const onFieldEdited = useCallback(() => {}, []); + + const editField = useCallback( + async (fieldName?: string, _uiAction: 'edit' | 'add' = 'edit') => { + if (!dataViewId) { + return; + } + + const dataViewInstance = await data.dataViews.get(dataViewId); + closeFieldEditor.current = await dataViewFieldEditor.openEditor({ + ctx: { + dataView: dataViewInstance, + }, + fieldName, + onSave: async () => { + onFieldEdited(); + }, + }); + }, + [dataViewId, data.dataViews, dataViewFieldEditor, onFieldEdited] + ); + + const handleAddField = useCallback(() => editField(undefined, 'add'), [editField]); + + const handleChangeDataView = useCallback( + (id: string) => { + selectDataView({ id, scope: [props.scope] }); + }, + [props.scope, selectDataView] + ); + + /** + * Dispatches an action that will result in data view update in the in-memory caches, + * as well as across all scopes where the data view is currently selected. + */ + const handleDataViewModified = useCallback( + (updatedDataView: DataView) => { + dispatch(sharedDataViewManagerSlice.actions.updateDataView(updatedDataView)); + }, + [dispatch] + ); + + const triggerConfig = useMemo(() => { + if (dataView.id === DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID) { + return { + label: 'Default Security Data View', + }; + } + + return { + label: dataView?.name || dataView?.id || 'Data view', + }; + }, [dataView]); + + const { adhocDataViews: adhocDataViewSpecs, dataViews } = useSelector(sharedStateSelector); + + const managedDataViews = useMemo(() => { + const managed: DataViewListItem[] = dataViews.map((spec) => ({ + id: spec.id ?? '', + title: spec.title ?? '', + name: spec.name, + })); + + return managed; + }, [dataViews]); + + const adhocDataViews = useMemo(() => { + return adhocDataViewSpecs.map((spec) => new DataView({ spec, fieldFormats })); + }, [adhocDataViewSpecs, fieldFormats]); + + return ( + + ); +}); + +DataViewPicker.displayName = 'DataviewPicker'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/constants.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/constants.ts new file mode 100644 index 0000000000000..24657f7f3a096 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/constants.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export const DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID = 'security-solution-default'; + +export { SourcererScopeName as DataViewManagerScopeName } from '../sourcerer/store/model'; + +export const SLICE_PREFIX = 'x-pack/security_solution/dataViewManager' as const; diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_browser_fields.test.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_browser_fields.test.ts new file mode 100644 index 0000000000000..8dbb63912088f --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_browser_fields.test.ts @@ -0,0 +1,53 @@ +/* + * 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 { renderHook } from '@testing-library/react'; +import { TestProviders } from '../../common/mock'; +import { useBrowserFields } from './use_browser_fields'; +import { DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, DataViewManagerScopeName } from '../constants'; +import { useDataView } from './use_data_view'; +import { type FieldSpec } from '@kbn/data-views-plugin/common'; + +jest.mock('./use_data_view', () => ({ + useDataView: jest.fn(), +})); + +describe('useBrowserFields', () => { + beforeAll(() => { + jest.mocked(useDataView).mockReturnValue({ + dataView: { + id: DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, + fields: { + '@timestamp': { + type: 'date', + name: '@timestamp', + } as FieldSpec, + }, + }, + status: 'ready', + }); + }); + + it('should call the useDataView hook and return browser fields map', () => { + const wrapper = renderHook(() => useBrowserFields(DataViewManagerScopeName.default), { + wrapper: TestProviders, + }); + + expect(wrapper.result.current).toMatchInlineSnapshot(` + Object { + "base": Object { + "fields": Object { + "@timestamp": Object { + "name": "@timestamp", + "type": "date", + }, + }, + }, + } + `); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_browser_fields.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_browser_fields.ts new file mode 100644 index 0000000000000..63256e2907212 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_browser_fields.ts @@ -0,0 +1,29 @@ +/* + * 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 { useMemo } from 'react'; +import type { BrowserFields } from '@kbn/timelines-plugin/common'; +import { type DataViewManagerScopeName } from '../constants'; +import { useDataView } from './use_data_view'; +import { getDataViewStateFromIndexFields } from '../../common/containers/source/use_data_view'; + +export const useBrowserFields = (scope: DataViewManagerScopeName): BrowserFields => { + const { dataView } = useDataView(scope); + + return useMemo(() => { + if (!dataView) { + return {}; + } + + const { browserFields } = getDataViewStateFromIndexFields( + dataView?.title ?? '', + dataView.fields + ); + + return browserFields; + }, [dataView]); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_data_view.test.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_data_view.test.ts new file mode 100644 index 0000000000000..0cdae6efe984e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_data_view.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 { renderHook } from '@testing-library/react'; +import { TestProviders } from '../../common/mock'; +import { DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, DataViewManagerScopeName } from '../constants'; +import { useDataView } from './use_data_view'; + +describe('useDataView', () => { + it('should return correct dataView from the store, based on the provided scope', () => { + const wrapper = renderHook((scope) => useDataView(scope), { + wrapper: TestProviders, + initialProps: DataViewManagerScopeName.default, + }); + + expect(wrapper.result.current.status).toEqual('ready'); + expect(wrapper.result.current.dataView).toMatchObject({ + id: DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, + }); + + wrapper.rerender(DataViewManagerScopeName.timeline); + + expect(wrapper.result.current.status).toEqual('ready'); + expect(wrapper.result.current.dataView).toMatchObject({ + id: 'mock-timeline-data-view', + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_data_view.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_data_view.ts new file mode 100644 index 0000000000000..081857cd1b585 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_data_view.ts @@ -0,0 +1,17 @@ +/* + * 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 { useSelector } from 'react-redux'; +import type { DataViewManagerScopeName } from '../constants'; +import { sourcererAdapterSelector } from '../redux/selectors'; + +/** + * Returns data view selection for given scopeName + */ +export const useDataView = (scopeName: DataViewManagerScopeName) => { + return useSelector(sourcererAdapterSelector(scopeName)); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_full_data_view.test.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_full_data_view.test.ts new file mode 100644 index 0000000000000..3be3145338db2 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_full_data_view.test.ts @@ -0,0 +1,65 @@ +/* + * 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 { renderHook } from '@testing-library/react'; +import { TestProviders } from '../../common/mock'; +import { DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, DataViewManagerScopeName } from '../constants'; + +import { useFullDataView } from './use_full_data_view'; +import { useDataView } from './use_data_view'; +import { type FieldSpec, DataView } from '@kbn/data-views-plugin/common'; + +jest.mock('./use_data_view', () => ({ + useDataView: jest.fn(), +})); + +describe('useFullDataView', () => { + describe('when data view is available', () => { + beforeAll(() => { + jest.mocked(useDataView).mockReturnValue({ + dataView: { + id: DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, + fields: { + '@timestamp': { + type: 'date', + name: '@timestamp', + } as FieldSpec, + }, + }, + status: 'ready', + }); + }); + + it('should return DataView instance', () => { + const wrapper = renderHook(() => useFullDataView(DataViewManagerScopeName.default), { + wrapper: TestProviders, + }); + + expect(wrapper.result.current).toBeInstanceOf(DataView); + }); + }); + + describe('when data view fields are not available', () => { + beforeEach(() => { + jest.mocked(useDataView).mockReturnValue({ + dataView: { + id: DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, + title: DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, + }, + status: 'pristine', + }); + }); + + it('should return undefined', () => { + const wrapper = renderHook(() => useFullDataView(DataViewManagerScopeName.default), { + wrapper: TestProviders, + }); + + expect(wrapper.result.current).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_full_data_view.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_full_data_view.ts new file mode 100644 index 0000000000000..3517264fb92f6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_full_data_view.ts @@ -0,0 +1,58 @@ +/* + * 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 { useMemo } from 'react'; +import { DataView, type DataViewSpec } from '@kbn/data-views-plugin/public'; +import isEmpty from 'lodash/isEmpty'; +import memoize from 'lodash/memoize'; +import type { FieldFormatsStartCommon } from '@kbn/field-formats-plugin/common'; + +import { useKibana } from '../../common/lib/kibana'; +import { type DataViewManagerScopeName } from '../constants'; +import { useDataView } from './use_data_view'; + +/** + * Creates a DataView from the provided DataViewSpec. + * + * This is a memoized function that will return the same DataView instance + * for the same dataViewSpec version or title. + */ +const dataViewFromSpec: ( + fieldFormats: FieldFormatsStartCommon, + dataViewSpec?: DataViewSpec +) => DataView | undefined = memoize( + (fieldFormats: FieldFormatsStartCommon, dataViewSpec?: DataViewSpec) => { + if (isEmpty(dataViewSpec?.fields)) { + return undefined; + } + + return new DataView({ spec: dataViewSpec, fieldFormats }); + }, + (...args) => { + return args[1]?.version ?? args[1]?.title; + } +); + +/* + * This hook should be used whenever we need the actual DataView and not just the spec for the + * selected data view. + */ +export const useFullDataView = ( + dataViewManagerScope: DataViewManagerScopeName +): DataView | undefined => { + const { + services: { fieldFormats }, + } = useKibana(); + const { dataView: dataViewSpec } = useDataView(dataViewManagerScope); + + const dataView = useMemo( + () => dataViewFromSpec(fieldFormats, dataViewSpec), + [dataViewSpec, fieldFormats] + ); + + return dataView; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_init_data_view_manager.test.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_init_data_view_manager.test.ts new file mode 100644 index 0000000000000..7a783d84721e0 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_init_data_view_manager.test.ts @@ -0,0 +1,21 @@ +/* + * 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 { renderHook } from '@testing-library/react'; +import { TestProviders } from '../../common/mock'; +import { useInitDataViewManager } from './use_init_data_view_manager'; + +describe('useInitDataViewPicker', () => { + it('should render', () => { + renderHook( + () => { + return useInitDataViewManager(); + }, + { wrapper: TestProviders } + ); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_init_data_view_manager.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_init_data_view_manager.ts new file mode 100644 index 0000000000000..d48af1ec4c650 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_init_data_view_manager.ts @@ -0,0 +1,67 @@ +/* + * 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 { useDispatch } from 'react-redux'; +import { useEffect } from 'react'; +import type { AnyAction, Dispatch, ListenerEffectAPI } from '@reduxjs/toolkit'; +import { + addListener as originalAddListener, + removeListener as originalRemoveListener, +} from '@reduxjs/toolkit'; +import type { RootState } from '../redux/reducer'; +import { useKibana } from '../../common/lib/kibana'; +import { createDataViewSelectedListener } from '../redux/listeners/data_view_selected'; +import { createInitListener } from '../redux/listeners/init_listener'; +import { useEnableExperimental } from '../../common/hooks/use_experimental_features'; +import { sharedDataViewManagerSlice } from '../redux/slices'; + +type OriginalListener = Parameters[0]; + +interface Listener { + actionCreator?: unknown; + effect: (action: Action, listenerApi: ListenerEffectAPI) => void; +} + +const addListener = (listener: Listener) => + originalAddListener(listener as unknown as OriginalListener); + +const removeListener = (listener: Listener) => + originalRemoveListener(listener as unknown as OriginalListener); + +/** + * Should only be used once in the application, on the top level of the rendering tree + */ +export const useInitDataViewManager = () => { + const dispatch = useDispatch(); + const services = useKibana().services; + const { newDataViewPickerEnabled } = useEnableExperimental(); + + useEffect(() => { + if (!newDataViewPickerEnabled) { + return; + } + + const dataViewsLoadingListener = createInitListener({ + dataViews: services.dataViews, + }); + + const dataViewSelectedListener = createDataViewSelectedListener({ + dataViews: services.dataViews, + }); + + dispatch(addListener(dataViewsLoadingListener)); + dispatch(addListener(dataViewSelectedListener)); + + // NOTE: this kicks off the data loading in the Data View Picker + dispatch(sharedDataViewManagerSlice.actions.init()); + + return () => { + dispatch(removeListener(dataViewsLoadingListener)); + dispatch(removeListener(dataViewSelectedListener)); + }; + }, [dispatch, newDataViewPickerEnabled, services.dataViews]); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_select_data_view.test.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_select_data_view.test.ts new file mode 100644 index 0000000000000..ba4b62ae6e0d0 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_select_data_view.test.ts @@ -0,0 +1,21 @@ +/* + * 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 { renderHook } from '@testing-library/react'; +import { TestProviders } from '../../common/mock'; +import { useSelectDataView } from './use_select_data_view'; + +describe('useSelectDataView', () => { + it('should render', () => { + renderHook( + () => { + return useSelectDataView(); + }, + { wrapper: TestProviders } + ); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_select_data_view.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_select_data_view.ts new file mode 100644 index 0000000000000..84bb4365015d1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_select_data_view.ts @@ -0,0 +1,33 @@ +/* + * 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 { useDispatch } from 'react-redux'; +import { useCallback } from 'react'; +import type { DataViewManagerScopeName } from '../constants'; +import { selectDataViewAsync } from '../redux/actions'; + +export const useSelectDataView = () => { + const dispatch = useDispatch(); + + return useCallback( + (params: { + id?: string | null; + /** + * List of patterns that will be used to construct the adhoc data view when + * .id param is not provided or the data view does not exist + */ + fallbackPatterns?: string[]; + /** + * Data view selection will be applied to the scopes listed here + */ + scope: DataViewManagerScopeName[]; + }) => { + dispatch(selectDataViewAsync(params)); + }, + [dispatch] + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_selected_patterns.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_selected_patterns.ts new file mode 100644 index 0000000000000..5228f875c9935 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/hooks/use_selected_patterns.ts @@ -0,0 +1,16 @@ +/* + * 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 { useMemo } from 'react'; +import type { DataViewManagerScopeName } from '../constants'; +import { useDataView } from './use_data_view'; + +export const useSelectedPatterns = (scope: DataViewManagerScopeName): string[] => { + const { dataView } = useDataView(scope); + + return useMemo(() => dataView?.title?.split(',') ?? [], [dataView?.title]); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/jest.config.js b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/jest.config.js new file mode 100644 index 0000000000000..06fa7c16dd4f6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/jest.config.js @@ -0,0 +1,19 @@ +/* + * 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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../../../..', + roots: ['/x-pack/solutions/security/plugins/security_solution/public/data_view_manager'], + coverageDirectory: + '/target/kibana-coverage/jest/x-pack/solutions/security/plugins/security_solution/public/data_view_manager', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/**/*.{ts,tsx}', + ], + moduleNameMapper: require('../../server/__mocks__/module_name_map'), +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/readme.md b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/readme.md new file mode 100644 index 0000000000000..ba7be275cf9e4 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/readme.md @@ -0,0 +1,35 @@ +# Data View Manager - **WIP** + +The successor to the Sourcerer component used across Security Solution to select data views. + +Design goals: + +- Redux-only state management +- Async side effects handled by redux toolkit listener middleware, no useEffect for critical async logic +- Cached (both on the client and backend side (via data views service)) +- Conforms to the platform standards (uses shared DataView infrastructure, types etc. only) +- Easy to understand +- Easy to use +- Handles missing saved data objects with Ad-Hoc Data Views +- Allows for data view management to the same extent Discover does it + +## Architecture + +### Multiple scopes + +Each section of the app has its own `scope` that holds the selected data view, for that scope only. This is what makes having different data views selected for the timelines and alerts table at the same time. +It is possible however to select the same data view for multiple scopes at the same time, by specifying multiple scopes in `useSelectDataView` hook call. See [useSelectDataView]('./hooks/use_select_data_view.ts') for reference. + +### Shared values + +Shared values (eg. list of currently loaded kibana data views) are stored in a dedicated portion of the state, `shared`. Values in this space are intended for reuse. + +### Async effects such as data view updates / creation / fetching + +We are currently using redux toolkit listener middleware to implement side effects logic in Data View Picker for Security Solution plugin. + +## Usage + +Unless absolutely necessary, we recommend to stick to the provided hooks to obtain or select data views for desired scopes. +Please don't use actions manually to alter the data view manager state from outside the data view manager folder. +These restrictions exist so that we can guarantee the maximum performance. \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/actions.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/actions.ts new file mode 100644 index 0000000000000..6b802468eb3a8 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/actions.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 { createAction } from '@reduxjs/toolkit'; + +import type { DataViewManagerScopeName } from '../constants'; +import { SLICE_PREFIX } from '../constants'; + +export const selectDataViewAsync = createAction<{ + id?: string | null; + /** + * Fallback patterns are used when the specific data view ID is undefined. This flow results in an ad-hoc data view creation + */ + fallbackPatterns?: string[]; + /** + * Specify one or more security solution scopes where the data view selection should be applied + */ + scope: DataViewManagerScopeName[]; +}>(`${SLICE_PREFIX}/selectDataView`); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/data_view_selected.test.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/data_view_selected.test.ts new file mode 100644 index 0000000000000..69f3491bec4d6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/data_view_selected.test.ts @@ -0,0 +1,170 @@ +/* + * 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 { createDataViewSelectedListener } from './data_view_selected'; +import { selectDataViewAsync } from '../actions'; +import type { DataViewsServicePublic, FieldSpec } from '@kbn/data-views-plugin/public'; +import type { AnyAction, Dispatch, ListenerEffectAPI } from '@reduxjs/toolkit'; +import type { RootState } from '../reducer'; +import { DataViewManagerScopeName } from '../../constants'; + +const mockDataViewsService = { + get: jest.fn(), + create: jest.fn().mockResolvedValue({ + id: 'adhoc_test-*', + isPersisted: () => false, + toSpec: () => ({ id: 'adhoc_test-*', title: 'test-*' }), + }), +} as unknown as DataViewsServicePublic; + +const mockedState: RootState = { + dataViewManager: { + analyzer: { + dataView: null, + status: 'pristine', + }, + timeline: { + dataView: null, + status: 'pristine', + }, + default: { + dataView: null, + status: 'pristine', + }, + detections: { + dataView: null, + status: 'pristine', + }, + shared: { + adhocDataViews: [ + { + id: 'adhoc_test-*', + title: 'test-*', + fields: { + '@timestamp': { + name: '@timestamp', + type: 'date', + } as unknown as FieldSpec, + }, + }, + ], + dataViews: [ + { + id: 'persisted_test-*', + title: 'test-*', + fields: { + '@timestamp': { + name: '@timestamp', + type: 'date', + } as unknown as FieldSpec, + }, + }, + ], + status: 'pristine', + }, + }, +}; + +const mockDispatch = jest.fn(); +const mockGetState = jest.fn(() => mockedState); + +const mockListenerApi = { + dispatch: mockDispatch, + getState: mockGetState, +} as unknown as ListenerEffectAPI>; + +describe('createDataViewSelectedListener', () => { + let listener: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + listener = createDataViewSelectedListener({ dataViews: mockDataViewsService }); + }); + + it('should return cached adhoc data view first', async () => { + await listener.effect( + selectDataViewAsync({ id: 'adhoc_test-*', scope: [DataViewManagerScopeName.default] }), + mockListenerApi + ); + + expect(mockDataViewsService.get).not.toHaveBeenCalled(); + }); + + it('should try to create data view if not cached', async () => { + await listener.effect( + selectDataViewAsync({ + id: 'fetched-id', + fallbackPatterns: ['test-*'], + scope: [DataViewManagerScopeName.default], + }), + mockListenerApi + ); + + // NOTE: we should check if the data view existence is checked + expect(mockDataViewsService.get).toHaveBeenCalledWith('fetched-id'); + + expect(mockDataViewsService.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'adhoc_test-*', + title: 'test-*', + }) + ); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ id: 'adhoc_test-*' }), + type: 'x-pack/security_solution/dataViewManager/default/setSelectedDataView', + }) + ); + }); + + it('should create adhoc data view if fetching fails', async () => { + await listener.effect( + selectDataViewAsync({ + fallbackPatterns: ['test-*'], + scope: [DataViewManagerScopeName.default], + }), + mockListenerApi + ); + + expect(mockDataViewsService.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'adhoc_test-*', + title: 'test-*', + }) + ); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ id: 'adhoc_test-*' }), + }) + ); + }); + + it('should dispatch an error if both fetching and creation fail', async () => { + jest + .mocked(mockDataViewsService) + .get.mockRejectedValueOnce(new Error('some random get data view failure')); + + jest + .mocked(mockDataViewsService) + .create.mockRejectedValueOnce(new Error('some random create data view failure')); + + await listener.effect( + selectDataViewAsync({ + fallbackPatterns: ['test-*'], + scope: [DataViewManagerScopeName.default], + }), + mockListenerApi + ); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'x-pack/security_solution/dataViewManager/default/dataViewSelectionError', + }) + ); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/data_view_selected.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/data_view_selected.ts new file mode 100644 index 0000000000000..e75d49941783f --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/data_view_selected.ts @@ -0,0 +1,108 @@ +/* + * 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 { DataView, DataViewsServicePublic } from '@kbn/data-views-plugin/public'; +import type { AnyAction, Dispatch, ListenerEffectAPI } from '@reduxjs/toolkit'; +import isEmpty from 'lodash/isEmpty'; +import type { RootState } from '../reducer'; +import { scopes } from '../reducer'; +import { selectDataViewAsync } from '../actions'; +import { sharedDataViewManagerSlice } from '../slices'; + +export const createDataViewSelectedListener = (dependencies: { + dataViews: DataViewsServicePublic; +}) => { + return { + actionCreator: selectDataViewAsync, + effect: async ( + action: ReturnType, + listenerApi: ListenerEffectAPI> + ) => { + let dataViewByIdError: unknown; + let adhocDataViewCreationError: unknown; + let dataViewById: DataView | null = null; + let adHocDataView: DataView | null = null; + + const state = listenerApi.getState(); + + const findCachedDataView = (id: string | null | undefined) => { + if (!id) { + return null; + } + + const cachedAdHocDataView = + state.dataViewManager.shared.adhocDataViews.find((dv) => dv.id === id) ?? null; + + const cachedPersistedDataView = + state.dataViewManager.shared.dataViews.find((dv) => dv.id === id) ?? null; + + const cachedDataView = cachedAdHocDataView || cachedPersistedDataView; + + // NOTE: validate if fields are available, otherwise dont return the view + // This is required to compute browserFields later. + // If the view is not returned here, it will be fetched further down this file, and that + // should return the full data view. + if (isEmpty(cachedDataView?.fields)) { + return null; + } + + return cachedDataView; + }; + + /** + * Try to locate the data view in cached entries first + */ + const cachedDataViewSpec = findCachedDataView(action.payload.id); + + if (!cachedDataViewSpec) { + try { + if (action.payload.id) { + dataViewById = await dependencies.dataViews.get(action.payload.id); + } + } catch (error: unknown) { + dataViewByIdError = error; + } + } + + if (!dataViewById) { + try { + const title = action.payload.fallbackPatterns?.join(',') ?? ''; + if (!title.length) { + throw new Error('empty adhoc title field'); + } + + adHocDataView = await dependencies.dataViews.create({ + id: `adhoc_${title}`, + title, + }); + if (adHocDataView) { + listenerApi.dispatch(sharedDataViewManagerSlice.actions.addDataView(adHocDataView)); + } + } catch (error: unknown) { + adhocDataViewCreationError = error; + } + } + + const resolvedSpecToUse = + cachedDataViewSpec || dataViewById?.toSpec() || adHocDataView?.toSpec(); + + action.payload.scope.forEach((scope) => { + const currentScopeActions = scopes[scope].actions; + if (resolvedSpecToUse) { + listenerApi.dispatch(currentScopeActions.setSelectedDataView(resolvedSpecToUse)); + } else if (dataViewByIdError || adhocDataViewCreationError) { + const err = dataViewByIdError || adhocDataViewCreationError; + listenerApi.dispatch( + currentScopeActions.dataViewSelectionError( + `An error occured when setting data view: ${err}` + ) + ); + } + }); + }, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/init_listener.test.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/init_listener.test.ts new file mode 100644 index 0000000000000..1954284b3bdc4 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/init_listener.test.ts @@ -0,0 +1,78 @@ +/* + * 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 { AnyAction, Dispatch, ListenerEffectAPI } from '@reduxjs/toolkit'; +import { mockDataViewManagerState } from '../mock'; +import { createInitListener } from './init_listener'; +import type { DataViewsServicePublic } from '@kbn/data-views-plugin/public'; +import type { RootState } from '../reducer'; +import { sharedDataViewManagerSlice } from '../slices'; +import { DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, DataViewManagerScopeName } from '../../constants'; +import { selectDataViewAsync } from '../actions'; + +const mockDataViewsService = { + get: jest.fn(), + create: jest.fn().mockResolvedValue({ + id: 'adhoc_test-*', + isPersisted: () => false, + toSpec: () => ({ id: 'adhoc_test-*', title: 'test-*' }), + }), + getAllDataViewLazy: jest.fn().mockReturnValue([]), +} as unknown as DataViewsServicePublic; + +const mockDispatch = jest.fn(); +const mockGetState = jest.fn(() => mockDataViewManagerState); + +const mockListenerApi = { + dispatch: mockDispatch, + getState: mockGetState, +} as unknown as ListenerEffectAPI>; + +describe('createInitListener', () => { + let listener: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + listener = createInitListener({ dataViews: mockDataViewsService }); + }); + + it('should load the data views and dispatch further actions', async () => { + await listener.effect(sharedDataViewManagerSlice.actions.init(), mockListenerApi); + + expect(jest.mocked(mockDataViewsService.getAllDataViewLazy)).toHaveBeenCalled(); + + expect(jest.mocked(mockListenerApi.dispatch)).toBeCalledWith( + sharedDataViewManagerSlice.actions.setDataViews([]) + ); + expect(jest.mocked(mockListenerApi.dispatch)).toBeCalledWith( + selectDataViewAsync({ + id: DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, + scope: [ + DataViewManagerScopeName.default, + DataViewManagerScopeName.timeline, + DataViewManagerScopeName.analyzer, + ], + }) + ); + }); + + describe('when data views fetch returns an error', () => { + beforeEach(() => { + jest + .mocked(mockDataViewsService.getAllDataViewLazy) + .mockRejectedValue(new Error('some loading error')); + }); + + it('should dispatch error correctly', async () => { + await listener.effect(sharedDataViewManagerSlice.actions.init(), mockListenerApi); + + expect(jest.mocked(mockListenerApi.dispatch)).toBeCalledWith( + sharedDataViewManagerSlice.actions.error() + ); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/init_listener.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/init_listener.ts new file mode 100644 index 0000000000000..101df21dc0aca --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/listeners/init_listener.ts @@ -0,0 +1,45 @@ +/* + * 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 { AnyAction, Dispatch, ListenerEffectAPI } from '@reduxjs/toolkit'; +import type { DataViewsServicePublic } from '@kbn/data-views-plugin/public'; +import type { RootState } from '../reducer'; +import { sharedDataViewManagerSlice } from '../slices'; +import { DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, DataViewManagerScopeName } from '../../constants'; +import { selectDataViewAsync } from '../actions'; + +export const createInitListener = (dependencies: { dataViews: DataViewsServicePublic }) => { + return { + actionCreator: sharedDataViewManagerSlice.actions.init, + effect: async ( + _action: AnyAction, + listenerApi: ListenerEffectAPI> + ) => { + try { + const dataViews = await dependencies.dataViews.getAllDataViewLazy(); + const dataViewSpecs = await Promise.all(dataViews.map((dataView) => dataView.toSpec())); + + listenerApi.dispatch(sharedDataViewManagerSlice.actions.setDataViews(dataViewSpecs)); + + // Preload the default data view for related scopes + // NOTE: we will remove this ideally and load only when particular dataview is necessary + listenerApi.dispatch( + selectDataViewAsync({ + id: DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, + scope: [ + DataViewManagerScopeName.default, + DataViewManagerScopeName.timeline, + DataViewManagerScopeName.analyzer, + ], + }) + ); + } catch (error: unknown) { + listenerApi.dispatch(sharedDataViewManagerSlice.actions.error()); + } + }, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/mock.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/mock.ts new file mode 100644 index 0000000000000..80b76f10a0b88 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/mock.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 { DataViewSpec } from './types'; +import { DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID } from '../constants'; +import { initialDataViewManagerState, type RootState } from './reducer'; +import { mockIndexFields } from '../../common/containers/source/mock'; + +const dataViewManagerState = structuredClone(initialDataViewManagerState).dataViewManager; + +const mockFieldMap: DataViewSpec['fields'] = Object.fromEntries( + mockIndexFields.map((field) => [field.name, field]) +); + +const mockDefaultDataViewSpec: DataViewSpec = { + fields: mockFieldMap, + id: DEFAULT_SECURITY_SOLUTION_DATA_VIEW_ID, + title: [ + '.siem-signals-spacename', + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'traces-apm*', + 'winlogbeat-*', + '-*elastic-cloud-logs-*', + ].join(), +}; + +export const mockDataViewManagerState: RootState = { + dataViewManager: { + ...dataViewManagerState, + timeline: { + ...dataViewManagerState.timeline, + dataView: { ...mockDefaultDataViewSpec, id: 'mock-timeline-data-view' }, + status: 'ready', + }, + default: { + ...dataViewManagerState.default, + dataView: mockDefaultDataViewSpec, + status: 'ready', + }, + analyzer: { + ...dataViewManagerState.analyzer, + dataView: mockDefaultDataViewSpec, + status: 'ready', + }, + }, +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/reducer.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/reducer.ts new file mode 100644 index 0000000000000..2a3e3d8b269b3 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/reducer.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 { combineReducers } from '@reduxjs/toolkit'; + +import { DataViewManagerScopeName } from '../constants'; +import { + createDataViewSelectionSlice, + initialScopeState, + initialSharedState, + sharedDataViewManagerSlice, +} from './slices'; + +export const scopes = { + [DataViewManagerScopeName.default]: createDataViewSelectionSlice( + DataViewManagerScopeName.default + ), + [DataViewManagerScopeName.timeline]: createDataViewSelectionSlice( + DataViewManagerScopeName.timeline + ), + [DataViewManagerScopeName.detections]: createDataViewSelectionSlice( + DataViewManagerScopeName.detections + ), + [DataViewManagerScopeName.analyzer]: createDataViewSelectionSlice( + DataViewManagerScopeName.analyzer + ), +} as const; + +export const dataViewManagerReducer = combineReducers({ + [DataViewManagerScopeName.default]: scopes[DataViewManagerScopeName.default].reducer, + [DataViewManagerScopeName.timeline]: scopes[DataViewManagerScopeName.timeline].reducer, + [DataViewManagerScopeName.detections]: scopes[DataViewManagerScopeName.detections].reducer, + [DataViewManagerScopeName.analyzer]: scopes[DataViewManagerScopeName.analyzer].reducer, + shared: sharedDataViewManagerSlice.reducer, +}); + +export type DataviewPickerState = ReturnType; + +export interface RootState { + dataViewManager: DataviewPickerState; +} + +export const initialDataViewManagerState: RootState = { + dataViewManager: { + shared: initialSharedState, + [DataViewManagerScopeName.default]: initialScopeState, + [DataViewManagerScopeName.timeline]: initialScopeState, + [DataViewManagerScopeName.detections]: initialScopeState, + [DataViewManagerScopeName.analyzer]: initialScopeState, + }, +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/selectors.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/selectors.ts new file mode 100644 index 0000000000000..7be851b6ae7ba --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/selectors.ts @@ -0,0 +1,26 @@ +/* + * 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 { createSelector } from '@reduxjs/toolkit'; + +import type { DataViewManagerScopeName } from '../constants'; +import type { RootState } from './reducer'; + +export const sourcererAdapterSelector = (scope: DataViewManagerScopeName) => + createSelector([(state: RootState) => state.dataViewManager], (dataViewManager) => { + const scopedState = dataViewManager[scope]; + + return { + ...scopedState, + dataView: scopedState.dataView ? scopedState.dataView : { title: '', id: '' }, + }; + }); + +export const sharedStateSelector = createSelector( + [(state: RootState) => state.dataViewManager], + (dataViewManager) => dataViewManager.shared +); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/slices.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/slices.ts new file mode 100644 index 0000000000000..ccf0b58f5e846 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/slices.ts @@ -0,0 +1,102 @@ +/* + * 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 { PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; +import type { DataViewSpec, DataView } from '@kbn/data-views-plugin/common'; +import type { DataViewManagerScopeName } from '../constants'; +import { SLICE_PREFIX } from '../constants'; +import type { ScopedDataViewSelectionState, SharedDataViewSelectionState } from './types'; +import { selectDataViewAsync } from './actions'; + +export const initialScopeState: ScopedDataViewSelectionState = { + dataView: null, + status: 'pristine', +}; + +export const initialSharedState: SharedDataViewSelectionState = { + dataViews: [], + adhocDataViews: [], + status: 'pristine', +}; + +export const sharedDataViewManagerSlice = createSlice({ + name: `${SLICE_PREFIX}/shared`, + initialState: initialSharedState, + reducers: { + setDataViews: (state, action: PayloadAction) => { + state.dataViews = action.payload; + state.status = 'ready'; + }, + updateDataView: (state, action: PayloadAction) => { + if (action.payload.isPersisted()) { + const dataViewIndex = state.dataViews.findIndex((dv) => dv.id === action.payload.id); + state.dataViews[dataViewIndex] = action.payload.toSpec(); + } else { + const adHocDataViewIndex = state.adhocDataViews.findIndex( + (dv) => dv.title === action.payload.title + ); + state.adhocDataViews[adHocDataViewIndex] = action.payload.toSpec(); + } + }, + addDataView: (state, action: PayloadAction) => { + const dataViewSpec = action.payload.toSpec(); + + if (action.payload.isPersisted()) { + if (state.dataViews.find((dv) => dv.id === dataViewSpec.id)) { + return; + } + + state.dataViews.push(dataViewSpec); + } else { + if (state.adhocDataViews.find((dv) => dv.title === dataViewSpec.title)) { + return; + } + + state.adhocDataViews.push(dataViewSpec); + } + }, + init: (state) => { + state.status = 'loading'; + }, + error: (state) => { + state.status = 'error'; + }, + }, +}); + +export const createDataViewSelectionSlice = (scopeName: T) => + createSlice({ + name: `${SLICE_PREFIX}/${scopeName}`, + initialState: initialScopeState, + reducers: { + setSelectedDataView: (state, action: PayloadAction) => { + state.dataView = action.payload; + state.status = 'ready'; + }, + dataViewSelectionError: (state, action: PayloadAction) => { + state.status = 'error'; + }, + }, + extraReducers(builder) { + builder.addCase(selectDataViewAsync, (state, action) => { + if (!action.payload.scope.includes(scopeName)) { + return state; + } + + state.status = 'loading'; + }); + + builder.addCase(sharedDataViewManagerSlice.actions.updateDataView, (state, action) => { + if (action.payload.isPersisted() && action.payload.id === state.dataView?.id) { + state.dataView = action.payload.toSpec(); + } else if (action.payload.title === state.dataView?.title) { + state.dataView = action.payload.toSpec(); + } + }); + }, + }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/types.ts b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/types.ts new file mode 100644 index 0000000000000..5cebc662f7ae8 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/data_view_manager/redux/types.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 type { DataViewSpec } from '@kbn/data-views-plugin/common'; + +export interface ScopedDataViewSelectionState { + dataView: DataViewSpec | null; + /** + * There are several states the picker can be in internally: + * - pristine - not initialized yet + * - loading - the dataView instance is loading with all associated fields, runtime fields, etc... + * - error - some kind of a problem during data init + * - ready - ready to provide index information to the client + */ + status: 'pristine' | 'loading' | 'error' | 'ready'; +} + +export interface SharedDataViewSelectionState { + dataViews: DataViewSpec[]; + adhocDataViews: DataViewSpec[]; + status: 'pristine' | 'loading' | 'error' | 'ready'; +} + +export { type DataViewSpec }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/preview_histogram.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/preview_histogram.test.tsx index 7a25095242945..8565136ebd31b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/preview_histogram.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/preview_histogram.test.tsx @@ -45,6 +45,7 @@ jest.mock('../../../../common/components/visualization_actions/use_visualization jest.mock('../../../../common/hooks/use_experimental_features', () => ({ useIsExperimentalFeatureEnabled: jest.fn(), + useEnableExperimental: jest.fn(() => jest.fn()), })); const mockVisualizationEmbeddable = VisualizationEmbeddable as unknown as jest.Mock; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_bulk_to_timeline.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_bulk_to_timeline.tsx index 2900f1196eff6..41db0052a3567 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_bulk_to_timeline.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_bulk_to_timeline.tsx @@ -13,11 +13,14 @@ import { getEsQueryConfig } from '@kbn/data-plugin/public'; import type { BulkActionsConfig } from '@kbn/response-ops-alerts-table/types'; import { dataTableActions, TableId, tableDefaults } from '@kbn/securitysolution-data-table'; import type { RunTimeMappings } from '@kbn/timelines-plugin/common/search_strategy'; +import { useEnableExperimental } from '../../../../common/hooks/use_experimental_features'; +import { useSelectedPatterns } from '../../../../data_view_manager/hooks/use_selected_patterns'; +import { useBrowserFields } from '../../../../data_view_manager/hooks/use_browser_fields'; +import { useDataView } from '../../../../data_view_manager/hooks/use_data_view'; import type { CustomBulkAction } from '../../../../../common/types'; import { combineQueries } from '../../../../common/lib/kuery'; import { useKibana } from '../../../../common/lib/kibana'; import { BULK_ADD_TO_TIMELINE_LIMIT } from '../../../../../common/constants'; -import { useSourcererDataView } from '../../../../sourcerer/containers'; import type { TimelineArgs } from '../../../../timelines/containers'; import { useTimelineEventsHandler } from '../../../../timelines/containers'; import { eventsViewerSelector } from '../../../../common/components/events_viewer/selectors'; @@ -31,6 +34,7 @@ import { sendBulkEventsToTimelineAction } from '../actions'; import type { CreateTimelineProps } from '../types'; import type { SourcererScopeName } from '../../../../sourcerer/store/model'; import type { Direction } from '../../../../../common/search_strategy'; +import { useSourcererDataView } from '../../../../sourcerer/containers'; const { setEventsLoading, setSelected } = dataTableActions; @@ -62,8 +66,13 @@ export const useAddBulkToTimelineAction = ({ scopeId, }: UseAddBulkToTimelineActionProps) => { const [disableActionOnSelectAll, setDisabledActionOnSelectAll] = useState(false); + const { newDataViewPickerEnabled } = useEnableExperimental(); + + const { dataView: experimentalDataView } = useDataView(scopeId); + const experimentalBrowserFields = useBrowserFields(scopeId); + const experimentalSelectedPatterns = useSelectedPatterns(scopeId); - const { + let { browserFields, dataViewId, sourcererDataView, @@ -71,6 +80,14 @@ export const useAddBulkToTimelineAction = ({ // in order to include the exclude filters in the search that are not stored in the timeline selectedPatterns, } = useSourcererDataView(scopeId); + + if (newDataViewPickerEnabled) { + dataViewId = experimentalDataView.id ?? ''; + browserFields = experimentalBrowserFields; + sourcererDataView = experimentalDataView; + selectedPatterns = experimentalSelectedPatterns; + } + const dispatch = useDispatch(); const { uiSettings } = useKibana().services; diff --git a/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/alerts_by_status.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/alerts_by_status.test.tsx index d3db4c743b53d..8d7d095963659 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/alerts_by_status.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/alerts_by_status.test.tsx @@ -19,7 +19,7 @@ import { useUserPrivileges } from '../../../../common/components/user_privileges jest.mock('../../../../common/components/user_privileges'); jest.mock('../../../../common/lib/kibana/kibana_react'); jest.mock('../../../../common/hooks/use_experimental_features', () => { - return { useIsExperimentalFeatureEnabled: jest.fn() }; + return { useIsExperimentalFeatureEnabled: jest.fn(), useEnableExperimental: () => jest.fn() }; }); jest.mock('../../../../common/components/visualization_actions/visualization_embeddable'); diff --git a/x-pack/solutions/security/plugins/security_solution/public/sourcerer/components/use_get_sourcerer_data_view.test.ts b/x-pack/solutions/security/plugins/security_solution/public/sourcerer/components/use_get_sourcerer_data_view.test.ts index bf687fa361db5..d2881b6091920 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/sourcerer/components/use_get_sourcerer_data_view.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/sourcerer/components/use_get_sourcerer_data_view.test.ts @@ -12,12 +12,14 @@ import { mockSourcererScope } from '../containers/mocks'; import { SourcererScopeName } from '../store/model'; import type { UseGetScopedSourcererDataViewArgs } from './use_get_sourcerer_data_view'; import { useGetScopedSourcererDataView } from './use_get_sourcerer_data_view'; +import { TestProviders } from '../../common/mock'; const renderHookCustom = (args: UseGetScopedSourcererDataViewArgs) => { return renderHook(({ sourcererScope }) => useGetScopedSourcererDataView({ sourcererScope }), { initialProps: { ...args, }, + wrapper: TestProviders, }); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/modal/actions/new_timeline_button.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/modal/actions/new_timeline_button.test.tsx index 92f9b874f8743..91ebd96c7a0c3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/modal/actions/new_timeline_button.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/modal/actions/new_timeline_button.test.tsx @@ -15,13 +15,11 @@ import { RowRendererValues } from '../../../../../common/api/timeline'; import { defaultUdtHeaders } from '../../timeline/body/column_headers/default_headers'; jest.mock('../../../../common/components/discover_in_timeline/use_discover_in_timeline_context'); -jest.mock('../../../../common/hooks/use_selector'); jest.mock('react-redux', () => { const original = jest.requireActual('react-redux'); return { ...original, - useSelector: jest.fn(), useDispatch: () => jest.fn(), }; }); @@ -52,8 +50,19 @@ describe('NewTimelineButton', () => { }); it('should call the correct action with clicking on the new timeline button', async () => { - const dataViewId = ''; - const selectedPatterns: string[] = []; + const dataViewId = 'security-solution'; + const selectedPatterns: string[] = [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'traces-apm*', + 'winlogbeat-*', + '-*elastic-cloud-logs-*', + '.siem-signals-spacename', + ]; const spy = jest.spyOn(timelineActions, 'createTimeline'); diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/modal/header/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/modal/header/index.test.tsx index 793cd12f99451..7ca9dc04547d5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/modal/header/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/modal/header/index.test.tsx @@ -15,6 +15,10 @@ import { useInspect } from '../../../../common/components/inspect/use_inspect'; import { useKibana } from '../../../../common/lib/kibana'; import { timelineActions } from '../../../store'; +jest.mock('../../../../common/hooks/use_experimental_features', () => ({ + useIsExperimentalFeatureEnabled: jest.fn(), + useEnableExperimental: jest.fn(() => jest.fn()), +})); jest.mock('../../../../sourcerer/containers'); jest.mock('../../../hooks/use_create_timeline'); jest.mock('../../../../common/components/inspect/use_inspect'); @@ -36,6 +40,7 @@ jest.mock('react-redux', () => { }, }, }, + dataViewManager: { timeline: {} }, }), }; }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/modal/header/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/modal/header/index.tsx index e42e856b9ca74..1c2edd067245e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/modal/header/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/modal/header/index.tsx @@ -18,6 +18,11 @@ import { useDispatch, useSelector } from 'react-redux'; import { getEsQueryConfig } from '@kbn/data-plugin/common'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; import styled from 'styled-components'; +import { useEnableExperimental } from '../../../../common/hooks/use_experimental_features'; +import { useSourcererDataView } from '../../../../sourcerer/containers'; +import { SourcererScopeName } from '../../../../sourcerer/store/model'; + +import { useDataView } from '../../../../data_view_manager/hooks/use_data_view'; import { NewTimelineButton } from '../actions/new_timeline_button'; import { OpenTimelineButton } from '../actions/open_timeline_button'; import { APP_ID } from '../../../../../common'; @@ -31,9 +36,7 @@ import { createHistoryEntry } from '../../../../common/utils/global_query_string import { timelineActions } from '../../../store'; import type { State } from '../../../../common/store'; import { useKibana } from '../../../../common/lib/kibana'; -import { useSourcererDataView } from '../../../../sourcerer/containers'; import { combineQueries } from '../../../../common/lib/kuery'; -import { SourcererScopeName } from '../../../../sourcerer/store/model'; import * as i18n from '../translations'; import { AddToFavoritesButton } from '../../add_to_favorites'; import { TimelineSaveStatus } from '../../save_status'; @@ -41,6 +44,8 @@ import { InspectButton } from '../../../../common/components/inspect'; import { InputsModelId } from '../../../../common/store/inputs/constants'; import { AttachToCaseButton } from '../actions/attach_to_case_button'; import { SaveTimelineButton } from '../actions/save_timeline_button'; +import { useBrowserFields } from '../../../../data_view_manager/hooks/use_browser_fields'; +import { DataViewManagerScopeName } from '../../../../data_view_manager/constants'; const whiteSpaceNoWrapCSS = { 'white-space': 'nowrap' }; const autoOverflowXCSS = { 'overflow-x': 'auto' }; @@ -70,7 +75,18 @@ interface FlyoutHeaderPanelProps { export const TimelineModalHeader = React.memo( ({ timelineId, openToggleRef }) => { const dispatch = useDispatch(); - const { browserFields, sourcererDataView } = useSourcererDataView(SourcererScopeName.timeline); + const { newDataViewPickerEnabled } = useEnableExperimental(); + + let { browserFields, sourcererDataView } = useSourcererDataView(SourcererScopeName.timeline); + + const { dataView: experimentalDataView } = useDataView(DataViewManagerScopeName.timeline); + const experimentalBrowserFields = useBrowserFields(DataViewManagerScopeName.timeline); + + if (newDataViewPickerEnabled) { + browserFields = experimentalBrowserFields; + sourcererDataView = experimentalDataView; + } + const { cases, uiSettings } = useKibana().services; const esQueryConfig = useMemo(() => getEsQueryConfig(uiSettings), [uiSettings]); const userCasesPermissions = cases.helpers.canUseCases([APP_ID]); diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/new_timeline/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/new_timeline/index.test.tsx index 0d1ed98f0bc99..d17aab5272752 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/new_timeline/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/new_timeline/index.test.tsx @@ -26,7 +26,6 @@ jest.mock('react-redux', () => { return { ...original, - useSelector: jest.fn(), useDispatch: () => jest.fn(), }; }); @@ -37,8 +36,19 @@ const renderNewTimelineButton = (type: TimelineType) => render(, { wrapper: TestProviders }); describe('NewTimelineButton', () => { - const dataViewId = ''; - const selectedPatterns: string[] = []; + const dataViewId = 'security-solution'; + const selectedPatterns: string[] = [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'traces-apm*', + 'winlogbeat-*', + '-*elastic-cloud-logs-*', + '.siem-signals-spacename', + ]; (useDiscoverInTimelineContext as jest.Mock).mockReturnValue({ resetDiscoverAppState: jest.fn(), }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/open_timeline/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/open_timeline/index.tsx index 7cd7cbe18927f..a7316579006c2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/open_timeline/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/open_timeline/index.tsx @@ -9,6 +9,9 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { encode } from '@kbn/rison'; +import { useEnableExperimental } from '../../../common/hooks/use_experimental_features'; +import { useSourcererDataView } from '../../../sourcerer/containers'; +import { useSelectedPatterns } from '../../../data_view_manager/hooks/use_selected_patterns'; import { RULE_FROM_EQL_URL_PARAM, RULE_FROM_TIMELINE_URL_PARAM, @@ -51,11 +54,11 @@ import { useTimelineStatus } from './use_timeline_status'; import { deleteTimelinesByIds } from '../../containers/api'; import type { Direction } from '../../../../common/search_strategy'; import { SourcererScopeName } from '../../../sourcerer/store/model'; -import { useSourcererDataView } from '../../../sourcerer/containers'; import { useStartTransaction } from '../../../common/lib/apm/use_start_transaction'; import { TIMELINE_ACTIONS } from '../../../common/lib/apm/user_actions'; import { defaultUdtHeaders } from '../timeline/body/column_headers/default_headers'; import { timelineDefaults } from '../../store/defaults'; +import { useDataView } from '../../../data_view_manager/hooks/use_data_view'; interface OwnProps { /** Displays open timeline in modal */ @@ -157,7 +160,16 @@ export const StatefulOpenTimelineComponent = React.memo( (state) => getTimeline(state, TimelineId.active)?.savedObjectId ?? '' ); - const { dataViewId, selectedPatterns } = useSourcererDataView(SourcererScopeName.timeline); + let { dataViewId, selectedPatterns } = useSourcererDataView(SourcererScopeName.timeline); + const { newDataViewPickerEnabled } = useEnableExperimental(); + + const { dataView: experimentalDataView } = useDataView(SourcererScopeName.timeline); + const experimentalSelectedPatterns = useSelectedPatterns(SourcererScopeName.timeline); + + if (newDataViewPickerEnabled) { + dataViewId = experimentalDataView?.id || ''; + selectedPatterns = experimentalSelectedPatterns; + } const { customTemplateTimelineCount, @@ -247,7 +259,7 @@ export const StatefulOpenTimelineComponent = React.memo( dispatchCreateNewTimeline({ id: TimelineId.active, columns: defaultUdtHeaders, - dataViewId, + dataViewId: dataViewId ?? '', indexNames: selectedPatterns, show: false, excludedRowRendererIds: timelineDefaults.excludedRowRendererIds, diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx index 4080e254303a2..c6347ed2ad9ad 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx @@ -20,6 +20,8 @@ import React, { useCallback, useMemo, useState } from 'react'; import styled from 'styled-components'; import { useDispatch } from 'react-redux'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { useEnableExperimental } from '../../../../common/hooks/use_experimental_features'; +import { useSelectedPatterns } from '../../../../data_view_manager/hooks/use_selected_patterns'; import { useKibana } from '../../../../common/lib/kibana'; import { DocumentDetailsRightPanelKey } from '../../../../flyout/document_details/shared/constants/panel_keys'; import type { TimelineResultNote } from '../types'; @@ -31,11 +33,11 @@ import * as i18n from './translations'; import { TimelineId } from '../../../../../common/types/timeline'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { SourcererScopeName } from '../../../../sourcerer/store/model'; -import { useSourcererDataView } from '../../../../sourcerer/containers'; import { useDeleteNote } from './hooks/use_delete_note'; import { getTimelineNoteSelector } from '../../timeline/tabs/notes/selectors'; import { DocumentEventTypes } from '../../../../common/lib/telemetry'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; +import { useSourcererDataView } from '../../../../sourcerer/containers'; export const NotePreviewsContainer = styled.section` padding-top: ${({ theme }) => `${theme.eui.euiSizeS}`}; @@ -52,7 +54,14 @@ const ToggleEventDetailsButtonComponent: React.FC eventId, timelineId, }) => { - const { selectedPatterns } = useSourcererDataView(SourcererScopeName.timeline); + const experimentalSelectedPatterns = useSelectedPatterns(SourcererScopeName.timeline); + let { selectedPatterns } = useSourcererDataView(SourcererScopeName.timeline); + + const { newDataViewPickerEnabled } = useEnableExperimental(); + + if (newDataViewPickerEnabled) { + selectedPatterns = experimentalSelectedPatterns; + } const { telemetry } = useKibana().services; const { openFlyout } = useExpandableFlyoutApi(); diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/open_timeline/use_update_timeline.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/open_timeline/use_update_timeline.tsx index 2927b2365221d..893773e227253 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/open_timeline/use_update_timeline.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/open_timeline/use_update_timeline.tsx @@ -8,13 +8,16 @@ import { useCallback } from 'react'; import { useDispatch } from 'react-redux'; import { isEmpty } from 'lodash/fp'; +import { useEnableExperimental } from '../../../common/hooks/use_experimental_features'; +import { DataViewManagerScopeName } from '../../../data_view_manager/constants'; +import { useSelectDataView } from '../../../data_view_manager/hooks/use_select_data_view'; import type { Note } from '../../../../common/api/timeline'; import { TimelineStatusEnum, TimelineTypeEnum } from '../../../../common/api/timeline'; import { createNote } from '../notes/helpers'; - -import { InputsModelId } from '../../../common/store/inputs/constants'; import { sourcererActions } from '../../../sourcerer/store'; import { SourcererScopeName } from '../../../sourcerer/store/model'; + +import { InputsModelId } from '../../../common/store/inputs/constants'; import { addNotes as dispatchAddNotes, updateNote as dispatchUpdateNote, @@ -36,8 +39,12 @@ import type { UpdateTimeline } from './types'; export const useUpdateTimeline = () => { const dispatch = useDispatch(); + const selectDataView = useSelectDataView(); + const { newDataViewPickerEnabled } = useEnableExperimental(); return useCallback( + // NOTE: this is only enabled for the data view picker test + // eslint-disable-next-line complexity ({ duplicate, id, @@ -58,13 +65,21 @@ export const useUpdateTimeline = () => { _timeline = { ...timeline, updated: undefined, changed: true, version: null }; } if (!isEmpty(_timeline.indexNames)) { - dispatch( - sourcererActions.setSelectedDataView({ - id: SourcererScopeName.timeline, - selectedDataViewId: _timeline.dataViewId, - selectedPatterns: _timeline.indexNames, - }) - ); + if (newDataViewPickerEnabled) { + selectDataView({ + id: _timeline.dataViewId, + fallbackPatterns: _timeline.indexNames, + scope: [DataViewManagerScopeName.timeline], + }); + } else { + dispatch( + sourcererActions.setSelectedDataView({ + id: SourcererScopeName.timeline, + selectedDataViewId: _timeline.dataViewId, + selectedPatterns: _timeline.indexNames, + }) + ); + } } if ( _timeline.status === TimelineStatusEnum.immutable && @@ -138,6 +153,6 @@ export const useUpdateTimeline = () => { ); } }, - [dispatch] + [dispatch, newDataViewPickerEnabled, selectDataView] ); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/data_providers/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/data_providers/index.tsx index 7a90b5254e445..b97d7af5b8d72 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/data_providers/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/data_providers/index.tsx @@ -13,11 +13,13 @@ import { v4 as uuidv4 } from 'uuid'; import { IS_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid'; import { EuiToolTip, EuiSuperSelect, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { useEnableExperimental } from '../../../../common/hooks/use_experimental_features'; +import { useBrowserFields } from '../../../../data_view_manager/hooks/use_browser_fields'; import { SourcererScopeName } from '../../../../sourcerer/store/model'; -import { useSourcererDataView } from '../../../../sourcerer/containers'; -import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { DroppableWrapper } from '../../../../common/components/drag_and_drop/droppable_wrapper'; import { droppableTimelineProvidersPrefix } from '../../../../common/components/drag_and_drop/helpers'; +import { useSourcererDataView } from '../../../../sourcerer/containers'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { Empty } from './empty'; import { Providers } from './providers'; @@ -106,7 +108,16 @@ const CustomTooltipDiv = styled.div` export const DataProviders = React.memo(({ timelineId }) => { const dispatch = useDispatch(); - const { browserFields } = useSourcererDataView(SourcererScopeName.timeline); + const { newDataViewPickerEnabled } = useEnableExperimental(); + + let { browserFields } = useSourcererDataView(SourcererScopeName.timeline); + + const experimentalBrowserFields = useBrowserFields(SourcererScopeName.timeline); + + if (newDataViewPickerEnabled) { + browserFields = experimentalBrowserFields; + } + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const dataProviders = useDeepEqualSelector( diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/kpi/kpi_container.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/kpi/kpi_container.tsx index dce963737fb5a..17f06f0bb415c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/kpi/kpi_container.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/kpi/kpi_container.tsx @@ -11,10 +11,12 @@ import { useSelector } from 'react-redux'; import { getEsQueryConfig } from '@kbn/data-plugin/common'; import type { TimerangeInput } from '@kbn/timelines-plugin/common'; import { EuiPanel } from '@elastic/eui'; +import { useEnableExperimental } from '../../../../common/hooks/use_experimental_features'; +import { useBrowserFields } from '../../../../data_view_manager/hooks/use_browser_fields'; import { TimelineId } from '../../../../../common/types'; -import { useSourcererDataView } from '../../../../sourcerer/containers'; import type { State } from '../../../../common/store'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { useSourcererDataView } from '../../../../sourcerer/containers'; import { SourcererScopeName } from '../../../../sourcerer/store/model'; import { TimelineKPIs } from './kpis'; import { useTimelineKpis } from '../../../containers/kpis'; @@ -26,16 +28,29 @@ import { endSelector, startSelector, } from '../../../../common/components/super_date_picker/selectors'; +import { useSelectedPatterns } from '../../../../data_view_manager/hooks/use_selected_patterns'; +import { useDataView } from '../../../../data_view_manager/hooks/use_data_view'; interface KpiExpandedProps { timelineId: string; } export const TimelineKpisContainer = ({ timelineId }: KpiExpandedProps) => { - const { browserFields, sourcererDataView, selectedPatterns } = useSourcererDataView( + const { newDataViewPickerEnabled } = useEnableExperimental(); + const experimentalBrowserFields = useBrowserFields(SourcererScopeName.timeline); + const { dataView: experimentalDataView } = useDataView(SourcererScopeName.timeline); + const experimentalSelectedPatterns = useSelectedPatterns(SourcererScopeName.timeline); + + let { browserFields, sourcererDataView, selectedPatterns } = useSourcererDataView( SourcererScopeName.timeline ); + if (newDataViewPickerEnabled) { + browserFields = experimentalBrowserFields; + sourcererDataView = experimentalDataView; + selectedPatterns = experimentalSelectedPatterns; + } + const { uiSettings } = useKibana().services; const esQueryConfig = useMemo(() => getEsQueryConfig(uiSettings), [uiSettings]); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/query_bar/eql/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/query_bar/eql/index.tsx index c378f5290cde2..8a4da610ed5e1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/query_bar/eql/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/query_bar/eql/index.tsx @@ -11,8 +11,9 @@ import { EuiOutsideClickDetector } from '@elastic/eui'; import { useDispatch } from 'react-redux'; import { css } from '@emotion/css'; +import { useEnableExperimental } from '../../../../../common/hooks/use_experimental_features'; +import { useDataView } from '../../../../../data_view_manager/hooks/use_data_view'; import type { EqlOptions } from '../../../../../../common/search_strategy'; -import { useSourcererDataView } from '../../../../../sourcerer/containers'; import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; import { SourcererScopeName } from '../../../../../sourcerer/store/model'; import { EqlQueryEdit } from '../../../../../detection_engine/rule_creation/components/eql_query_edit'; @@ -22,6 +23,8 @@ import type { FormSchema, FormSubmitHandler } from '../../../../../shared_import import { Form, UseField, useForm } from '../../../../../shared_imports'; import { timelineActions } from '../../../../store'; import { getEqlOptions } from './selectors'; +import { useSelectedPatterns } from '../../../../../data_view_manager/hooks/use_selected_patterns'; +import { useSourcererDataView } from '../../../../../sourcerer/containers'; interface TimelineEqlQueryBar { index: string[]; @@ -59,12 +62,23 @@ export const EqlQueryBarTimeline = memo(({ timelineId }: { timelineId: string }) const getOptionsSelected = useMemo(() => getEqlOptions(), []); const eqlOptions = useDeepEqualSelector((state) => getOptionsSelected(state, timelineId)); - const { + let { loading: indexPatternsLoading, sourcererDataView, selectedPatterns, } = useSourcererDataView(SourcererScopeName.timeline); + const { newDataViewPickerEnabled } = useEnableExperimental(); + + const { dataView: experimentalDataView, status } = useDataView(SourcererScopeName.timeline); + const experimentalSelectedPatterns = useSelectedPatterns(SourcererScopeName.timeline); + + if (newDataViewPickerEnabled) { + indexPatternsLoading = status !== 'ready'; + sourcererDataView = experimentalDataView; + selectedPatterns = experimentalSelectedPatterns; + } + const initialState = useMemo( () => ({ ...defaultValues, @@ -165,7 +179,7 @@ export const EqlQueryBarTimeline = memo(({ timelineId }: { timelineId: string }) /* Force casting `sourcererDataView` to `DataViewBase` is required since EqlQueryEdit accepts DataViewBase but `useSourcererDataView()` returns `DataViewSpec`. - + When using `UseField` with `EqlQueryBar` such casting isn't required by TS since `UseField` component props are types as `Record`. */ return ( diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/query_bar/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/query_bar/index.tsx index 1bb39aa4796d2..4b5f73082d95e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/query_bar/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/query_bar/index.tsx @@ -13,8 +13,9 @@ import type { Filter, Query } from '@kbn/es-query'; import { FilterStateStore } from '@kbn/es-query'; import type { FilterManager, SavedQuery, SavedQueryTimeFilter } from '@kbn/data-plugin/public'; import styled from '@emotion/styled'; +import { useEnableExperimental } from '../../../../common/hooks/use_experimental_features'; +import { useDataView } from '../../../../data_view_manager/hooks/use_data_view'; import { InputsModelId } from '../../../../common/store/inputs/constants'; -import { useSourcererDataView } from '../../../../sourcerer/containers'; import { SourcererScopeName } from '../../../../sourcerer/store/model'; import { @@ -29,6 +30,8 @@ import type { DataProvider } from '../data_providers/data_provider'; import { TIMELINE_FILTER_DROP_AREA, buildGlobalQuery, getNonDropAreaFilters } from '../helpers'; import { timelineActions } from '../../../store'; import type { KueryFilterQuery, KueryFilterQueryKind } from '../../../../../common/types/timeline'; +import { useBrowserFields } from '../../../../data_view_manager/hooks/use_browser_fields'; +import { useSourcererDataView } from '../../../../sourcerer/containers'; export interface QueryBarTimelineComponentProps { dataProviders: DataProvider[]; @@ -110,7 +113,17 @@ export const QueryBarTimeline = memo( const [dateRangeTo, setDateRangTo] = useState( toStr != null ? toStr : new Date(to).toISOString() ); - const { browserFields, sourcererDataView } = useSourcererDataView(SourcererScopeName.timeline); + let { browserFields, sourcererDataView } = useSourcererDataView(SourcererScopeName.timeline); + + const { newDataViewPickerEnabled } = useEnableExperimental(); + const { dataView: experimentalDataView } = useDataView(SourcererScopeName.timeline); + const experimentalBrowserFields = useBrowserFields(SourcererScopeName.timeline); + + if (newDataViewPickerEnabled) { + sourcererDataView = experimentalDataView; + browserFields = experimentalBrowserFields; + } + const [savedQuery, setSavedQuery] = useState(undefined); const [filterQueryConverted, setFilterQueryConverted] = useState({ query: filterQuery != null ? filterQuery.expression : '', diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/search_or_filter/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/search_or_filter/index.tsx index d3233858813ae..5c2c3b1cfeede 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/search_or_filter/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/search_or_filter/index.tsx @@ -17,11 +17,12 @@ import type { FilterManager } from '@kbn/data-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/common'; import { FilterItems } from '@kbn/unified-search-plugin/public'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { useEnableExperimental } from '../../../../common/hooks/use_experimental_features'; +import { useDataView } from '../../../../data_view_manager/hooks/use_data_view'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { useKibana } from '../../../../common/lib/kibana'; import { SourcererScopeName } from '../../../../sourcerer/store/model'; -import { useSourcererDataView } from '../../../../sourcerer/containers'; import type { State, inputsModel } from '../../../../common/store'; import { inputsSelectors } from '../../../../common/store'; import { timelineActions, timelineSelectors } from '../../../store'; @@ -32,6 +33,7 @@ import { SearchOrFilter } from './search_or_filter'; import { setDataProviderVisibility } from '../../../store/actions'; import { getNonDropAreaFilters } from '../helpers'; import * as i18n from './translations'; +import { useSourcererDataView } from '../../../../sourcerer/containers'; interface OwnProps { filterManager: FilterManager; @@ -73,7 +75,14 @@ const StatefulSearchOrFilterComponent = React.memo( services: { data }, } = useKibana(); - const { sourcererDataView } = useSourcererDataView(SourcererScopeName.timeline); + let { sourcererDataView } = useSourcererDataView(SourcererScopeName.timeline); + + const { dataView: experimentalDataView } = useDataView(SourcererScopeName.timeline); + const { newDataViewPickerEnabled } = useEnableExperimental(); + + if (newDataViewPickerEnabled) { + sourcererDataView = experimentalDataView; + } const getIsDataProviderVisible = useMemo( () => timelineSelectors.dataProviderVisibilitySelector(), diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/search_or_filter/search_or_filter.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/search_or_filter/search_or_filter.tsx index 3ec2a965d8021..564f5131b7e6b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/search_or_filter/search_or_filter.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/search_or_filter/search_or_filter.tsx @@ -11,6 +11,7 @@ import styled from 'styled-components'; import type { Filter } from '@kbn/es-query'; import type { FilterManager } from '@kbn/data-plugin/public'; +import { useEnableExperimental } from '../../../../common/hooks/use_experimental_features'; import { type TimelineType, TimelineTypeEnum } from '../../../../../common/api/timeline'; import { InputsModelId } from '../../../../common/store/inputs/constants'; import type { KqlMode } from '../../../store/model'; @@ -19,10 +20,11 @@ import { SuperDatePicker } from '../../../../common/components/super_date_picker import type { KueryFilterQuery } from '../../../../../common/types/timeline'; import type { DataProvider } from '../data_providers/data_provider'; import { QueryBarTimeline } from '../query_bar'; +import { Sourcerer } from '../../../../sourcerer/components'; +import { DataViewPicker } from '../../../../data_view_manager/components/data_view_picker'; import { TimelineDatePickerLock } from '../date_picker_lock'; import { SourcererScopeName } from '../../../../sourcerer/store/model'; -import { Sourcerer } from '../../../../sourcerer/components'; import { DATA_PROVIDER_HIDDEN_EMPTY, DATA_PROVIDER_HIDDEN_POPULATED, @@ -85,6 +87,7 @@ export const SearchOrFilter = React.memo( toggleDataProviderVisibility, timelineType, }) => { + const { newDataViewPickerEnabled } = useEnableExperimental(); const isDataProviderEmpty = useMemo(() => dataProviders?.length === 0, [dataProviders]); const dataProviderIconTooltipContent = useMemo(() => { @@ -112,7 +115,11 @@ export const SearchOrFilter = React.memo( responsive={false} > - + {newDataViewPickerEnabled ? ( + + ) : ( + + )} = ({ timelineId }) services: { customDataService: discoverDataService, discover, - dataViews: dataViewService, savedSearch: savedSearchService, + dataViews: dataViewService, }, } = useKibana(); const { @@ -59,9 +61,18 @@ export const DiscoverTabContent: FC = ({ timelineId }) const dispatch = useDispatch(); + const { newDataViewPickerEnabled } = useEnableExperimental(); + const { dataView: experimentalDataView } = useDataView(SourcererScopeName.detections); + const { dataViewId } = useSourcererDataView(SourcererScopeName.detections); - const [dataView, setDataView] = useState(); + // eslint-disable-next-line prefer-const + let [dataView, setDataView] = useState(); + + if (newDataViewPickerEnabled) { + dataView = experimentalDataView; + } + const [discoverTimerange, setDiscoverTimerange] = useState(); const discoverAppStateSubscription = useRef(); @@ -69,6 +80,12 @@ export const DiscoverTabContent: FC = ({ timelineId }) const discoverSavedSearchStateSubscription = useRef(); const discoverTimerangeSubscription = useRef(); + // TODO: should not be here, used to make discover container work I suppose + useEffect(() => { + if (!dataViewId) return; + dataViewService.get(dataViewId).then((dv) => setDataView(dv?.toSpec?.())); + }, [dataViewId, dataViewService]); + const { discoverStateContainer, setDiscoverStateContainer, @@ -160,11 +177,6 @@ export const DiscoverTabContent: FC = ({ timelineId }) canSaveTimeline, ]); - useEffect(() => { - if (!dataViewId) return; - dataViewService.get(dataViewId).then(setDataView); - }, [dataViewId, dataViewService]); - useEffect(() => { const unSubscribeAll = () => { [ @@ -277,7 +289,8 @@ export const DiscoverTabContent: FC = ({ timelineId }) const DiscoverContainer = discover.DiscoverContainer; - const isLoading = Boolean(!dataView); + // TODO this should not work like that + const isLoading = !dataView; return ( diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/pinned/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/pinned/index.tsx index f70d51de3d755..5edbf51c69265 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/pinned/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/pinned/index.tsx @@ -13,6 +13,9 @@ import deepEqual from 'fast-deep-equal'; import type { EuiDataGridControlColumn } from '@elastic/eui'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import type { RunTimeMappings } from '@kbn/timelines-plugin/common/search_strategy'; +import { useSourcererDataView } from '../../../../../sourcerer/containers'; +import { useDataView } from '../../../../../data_view_manager/hooks/use_data_view'; +import { useSelectedPatterns } from '../../../../../data_view_manager/hooks/use_selected_patterns'; import { useFetchNotes } from '../../../../../notes/hooks/use_fetch_notes'; import { DocumentDetailsLeftPanelKey, @@ -25,8 +28,10 @@ import { useTimelineEvents } from '../../../../containers'; import { requiredFieldsForActions } from '../../../../../detections/components/alerts_table/default_config'; import { SourcererScopeName } from '../../../../../sourcerer/store/model'; import { timelineDefaults } from '../../../../store/defaults'; -import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; -import { useSourcererDataView } from '../../../../../sourcerer/containers'; +import { + useEnableExperimental, + useIsExperimentalFeatureEnabled, +} from '../../../../../common/hooks/use_experimental_features'; import type { TimelineModel } from '../../../../store/model'; import type { State } from '../../../../../common/store'; import { TimelineTabs } from '../../../../../../common/types/timeline'; @@ -78,10 +83,22 @@ export const PinnedTabContentComponent: React.FC = ({ const [pageIndex, setPageIndex] = useState(0); const { telemetry } = useKibana().services; - const { dataViewId, sourcererDataView, selectedPatterns } = useSourcererDataView( + + const { newDataViewPickerEnabled } = useEnableExperimental(); + + let { dataViewId, sourcererDataView, selectedPatterns } = useSourcererDataView( SourcererScopeName.timeline ); + const experimentalSelectedPatterns = useSelectedPatterns(SourcererScopeName.timeline); + const { dataView: experimentalDataView } = useDataView(SourcererScopeName.timeline); + + if (newDataViewPickerEnabled) { + selectedPatterns = experimentalSelectedPatterns; + sourcererDataView = experimentalDataView; + dataViewId = experimentalDataView.id ?? ''; + } + const filterQuery = useMemo(() => { if (isEmpty(pinnedEventIds)) { return ''; @@ -144,7 +161,7 @@ export const PinnedTabContentComponent: React.FC = ({ endDate: '', id: `pinned-${timelineId}`, indexNames: selectedPatterns, - dataViewId, + dataViewId: dataViewId ?? '', fields: timelineQueryFields, limit: itemsPerPage, filterQuery, diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.tsx index 7a08a22ef5a81..4f5c1cd9eb4ce 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/query/index.tsx @@ -15,6 +15,9 @@ import { getEsQueryConfig } from '@kbn/data-plugin/common'; import { DataLoadingState } from '@kbn/unified-data-table'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import type { RunTimeMappings } from '@kbn/timelines-plugin/common/search_strategy'; +import { useSelectedPatterns } from '../../../../../data_view_manager/hooks/use_selected_patterns'; +import { useBrowserFields } from '../../../../../data_view_manager/hooks/use_browser_fields'; +import { useDataView } from '../../../../../data_view_manager/hooks/use_data_view'; import { useFetchNotes } from '../../../../../notes/hooks/use_fetch_notes'; import { DocumentDetailsLeftPanelKey, @@ -22,7 +25,10 @@ import { } from '../../../../../flyout/document_details/shared/constants/panel_keys'; import { LeftPanelNotesTab } from '../../../../../flyout/document_details/left'; import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; -import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; +import { + useEnableExperimental, + useIsExperimentalFeatureEnabled, +} from '../../../../../common/hooks/use_experimental_features'; import { useTimelineDataFilters } from '../../../../containers/use_timeline_data_filters'; import { InputsModelId } from '../../../../../common/store/inputs/constants'; import { useInvalidFilterQuery } from '../../../../../common/hooks/use_invalid_filter_query'; @@ -83,7 +89,15 @@ export const QueryTabContentComponent: React.FC = ({ eventIdToNoteIds, }) => { const dispatch = useDispatch(); - const { + const { newDataViewPickerEnabled } = useEnableExperimental(); + + const { dataView: experimentalDataView, status: sourcererStatus } = useDataView( + SourcererScopeName.timeline + ); + const experimentalBrowserFields = useBrowserFields(SourcererScopeName.timeline); + const experimentalSelectedPatterns = useSelectedPatterns(SourcererScopeName.timeline); + + let { browserFields, dataViewId, loading: loadingSourcerer, @@ -92,6 +106,15 @@ export const QueryTabContentComponent: React.FC = ({ selectedPatterns, sourcererDataView, } = useSourcererDataView(SourcererScopeName.timeline); + + if (newDataViewPickerEnabled) { + loadingSourcerer = sourcererStatus !== 'ready'; + sourcererDataView = experimentalDataView; + browserFields = experimentalBrowserFields; + selectedPatterns = experimentalSelectedPatterns; + dataViewId = experimentalDataView.id ?? ''; + } + /* * `pageIndex` needs to be maintained for each table in each tab independently * and consequently it cannot be the part of common redux state @@ -174,7 +197,7 @@ export const QueryTabContentComponent: React.FC = ({ const [dataLoadingState, { events, inspect, totalCount, loadNextBatch, refreshedAt, refetch }] = useTimelineEvents({ - dataViewId, + dataViewId: dataViewId ?? '', endDate: end, fields: timelineQueryFieldsFromColumns, filterQuery: combinedQueries?.filterQuery, diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/session/use_session_view.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/session/use_session_view.tsx index 6460dc220a934..01b363adad1d1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/session/use_session_view.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/session/use_session_view.tsx @@ -18,8 +18,9 @@ import { import { useDispatch } from 'react-redux'; import { dataTableSelectors, tableDefaults } from '@kbn/securitysolution-data-table'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { useEnableExperimental } from '../../../../../common/hooks/use_experimental_features'; +import { useSelectedPatterns } from '../../../../../data_view_manager/hooks/use_selected_patterns'; import { DocumentDetailsRightPanelKey } from '../../../../../flyout/document_details/shared/constants/panel_keys'; -import { useSourcererDataView } from '../../../../../sourcerer/containers'; import { getScopedActions, isActiveTimeline, @@ -42,6 +43,7 @@ import { timelineDefaults } from '../../../../store/defaults'; import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; import { DocumentEventTypes } from '../../../../../common/lib/telemetry'; import { isFullScreen } from '../../helpers'; +import { useSourcererDataView } from '../../../../../sourcerer/containers'; interface NavigationProps { fullScreen: boolean; @@ -279,7 +281,13 @@ export const useSessionView = ({ scopeId, height }: { scopeId: string; height?: [globalFullScreen, scopeId, timelineFullScreen] ); - const { selectedPatterns } = useSourcererDataView(SourcererScopeName.detections); + const { newDataViewPickerEnabled } = useEnableExperimental(); + let { selectedPatterns } = useSourcererDataView(SourcererScopeName.detections); + + const experimentalSelectedPatterns = useSelectedPatterns(SourcererScopeName.detections); + if (newDataViewPickerEnabled) { + selectedPatterns = experimentalSelectedPatterns; + } const alertsIndex = useMemo(() => selectedPatterns.join(','), [selectedPatterns]); const { openFlyout } = useExpandableFlyoutApi(); diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_columns.test.ts b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_columns.test.ts index 66162fe82e458..767f56996bb15 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_columns.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_columns.test.ts @@ -13,6 +13,7 @@ import type { ColumnHeaderOptions } from '../../../../../../common/types/timelin jest.mock('../../../../../common/hooks/use_experimental_features', () => ({ useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), + useEnableExperimental: jest.fn(() => jest.fn()), })); describe('useTimelineColumns', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_columns.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_columns.tsx index 006c6ba1eb679..3ba36cb9493bd 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_columns.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_columns.tsx @@ -6,6 +6,8 @@ */ import { useMemo } from 'react'; +import { useEnableExperimental } from '../../../../../common/hooks/use_experimental_features'; +import { useBrowserFields } from '../../../../../data_view_manager/hooks/use_browser_fields'; import { SourcererScopeName } from '../../../../../sourcerer/store/model'; import { useSourcererDataView } from '../../../../../sourcerer/containers'; import { requiredFieldsForActions } from '../../../../../detections/components/alerts_table/default_config'; @@ -14,7 +16,13 @@ import type { ColumnHeaderOptions } from '../../../../../../common/types'; import { memoizedGetTimelineColumnHeaders } from './utils'; export const useTimelineColumns = (columns: ColumnHeaderOptions[]) => { - const { browserFields } = useSourcererDataView(SourcererScopeName.timeline); + const { newDataViewPickerEnabled } = useEnableExperimental(); + let { browserFields } = useSourcererDataView(SourcererScopeName.timeline); + const experimentalBrowserFields = useBrowserFields(SourcererScopeName.timeline); + + if (newDataViewPickerEnabled) { + browserFields = experimentalBrowserFields; + } const localColumns = useMemo(() => columns ?? defaultUdtHeaders, [columns]); diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/containers/use_timeline_data_filters.ts b/x-pack/solutions/security/plugins/security_solution/public/timelines/containers/use_timeline_data_filters.ts index cd27ff06020ba..aaa2487209bd2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/containers/use_timeline_data_filters.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/containers/use_timeline_data_filters.ts @@ -13,7 +13,9 @@ import { endSelector, } from '../../common/components/super_date_picker/selectors'; import { SourcererScopeName } from '../../sourcerer/store/model'; +import { useSelectedPatterns } from '../../data_view_manager/hooks/use_selected_patterns'; import { useSourcererDataView } from '../../sourcerer/containers'; +import { useEnableExperimental } from '../../common/hooks/use_experimental_features'; export function useTimelineDataFilters(isActiveTimelines: boolean) { const getStartSelector = useMemo(() => startSelector(), []); @@ -42,7 +44,13 @@ export function useTimelineDataFilters(isActiveTimelines: boolean) { } }); - const { selectedPatterns: analyzerPatterns } = useSourcererDataView(SourcererScopeName.analyzer); + const { newDataViewPickerEnabled } = useEnableExperimental(); + let { selectedPatterns: analyzerPatterns } = useSourcererDataView(SourcererScopeName.analyzer); + const experimentalAnalyzerPatterns = useSelectedPatterns(SourcererScopeName.analyzer); + + if (newDataViewPickerEnabled) { + analyzerPatterns = experimentalAnalyzerPatterns; + } return useMemo(() => { return { diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/hooks/use_create_timeline.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/hooks/use_create_timeline.tsx index 2f80e969cab5e..596e036f3f1dc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/hooks/use_create_timeline.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/hooks/use_create_timeline.tsx @@ -14,13 +14,18 @@ import { TimelineId } from '../../../common/types/timeline'; import { type TimelineType, TimelineTypeEnum } from '../../../common/api/timeline'; import { useDeepEqualSelector } from '../../common/hooks/use_selector'; import { inputsActions, inputsSelectors } from '../../common/store/inputs'; -import { sourcererActions, sourcererSelectors } from '../../sourcerer/store'; -import { SourcererScopeName } from '../../sourcerer/store/model'; import { appActions } from '../../common/store/app'; import type { TimeRange } from '../../common/store/inputs/model'; import { useDiscoverInTimelineContext } from '../../common/components/discover_in_timeline/use_discover_in_timeline_context'; import { defaultUdtHeaders } from '../components/timeline/body/column_headers/default_headers'; import { timelineDefaults } from '../store/defaults'; +import { useSelectDataView } from '../../data_view_manager/hooks/use_select_data_view'; +import { DataViewManagerScopeName } from '../../data_view_manager/constants'; +import { useDataView } from '../../data_view_manager/hooks/use_data_view'; +import { useSelectedPatterns } from '../../data_view_manager/hooks/use_selected_patterns'; +import { sourcererActions, sourcererSelectors } from '../../sourcerer/store'; +import { SourcererScopeName } from '../../sourcerer/store/model'; +import { useEnableExperimental } from '../../common/hooks/use_experimental_features'; export interface UseCreateTimelineParams { /** @@ -48,15 +53,28 @@ export const useCreateTimeline = ({ onClick, }: UseCreateTimelineParams): ((options?: { timeRange?: TimeRange }) => Promise) => { const dispatch = useDispatch(); - const { id: dataViewId, patternList: selectedPatterns } = useSelector( + + let { id: dataViewId, patternList: selectedPatterns } = useSelector( sourcererSelectors.defaultDataView ) ?? { id: '', patternList: [] }; + const { newDataViewPickerEnabled } = useEnableExperimental(); + const { dataView: experimentalDataView } = useDataView(DataViewManagerScopeName.default); + + const experimentalSelectedPatterns = useSelectedPatterns(DataViewManagerScopeName.default); + + if (newDataViewPickerEnabled) { + dataViewId = experimentalDataView.id ?? ''; + selectedPatterns = experimentalSelectedPatterns; + } + const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen(); const globalTimeRange = useDeepEqualSelector(inputsSelectors.globalTimeRangeSelector); const { resetDiscoverAppState } = useDiscoverInTimelineContext(); + const setSelectedDataView = useSelectDataView(); + const createTimeline = useCallback( ({ id, @@ -72,6 +90,13 @@ export const useCreateTimeline = ({ if (id === TimelineId.active && timelineFullScreen) { setTimelineFullScreen(false); } + + setSelectedDataView({ + id: dataViewId, + fallbackPatterns: selectedPatterns, + scope: [DataViewManagerScopeName.timeline], + }); + dispatch( sourcererActions.setSelectedDataView({ id: SourcererScopeName.timeline, @@ -120,13 +145,14 @@ export const useCreateTimeline = ({ } }, [ - dispatch, globalTimeRange, + timelineFullScreen, + dispatch, dataViewId, selectedPatterns, - setTimelineFullScreen, - timelineFullScreen, + setSelectedDataView, timelineType, + setTimelineFullScreen, ] ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/pages/timelines_page.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/pages/timelines_page.test.tsx index ae82e2a381010..6f6355ac9f88b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/pages/timelines_page.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/pages/timelines_page.test.tsx @@ -25,6 +25,12 @@ jest.mock('react-router-dom', () => { jest.mock('../../overview/components/events_by_dataset'); jest.mock('../../sourcerer/containers'); jest.mock('../../common/components/user_privileges'); +jest.mock('../../data_view_manager/hooks/use_full_data_view', () => ({ + useFullDataView: jest.fn(() => ({ matchedIndices: [] })), +})); +jest.mock('../../common/hooks/use_experimental_features', () => ({ + useEnableExperimental: jest.fn(() => jest.fn()), +})); describe('TimelinesPage', () => { let wrapper: ShallowWrapper; diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/pages/timelines_page.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/pages/timelines_page.tsx index d213eeaf8f5f0..c2a0c624575d7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/pages/timelines_page.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/pages/timelines_page.tsx @@ -16,15 +16,28 @@ import { useUserPrivileges } from '../../common/components/user_privileges'; import { StatefulOpenTimeline } from '../components/open_timeline'; import * as i18n from './translations'; import { SecurityPageName } from '../../app/types'; -import { useSourcererDataView } from '../../sourcerer/containers'; import { EmptyPrompt } from '../../common/components/empty_prompt'; import { SecurityRoutePageWrapper } from '../../common/components/security_route_page_wrapper'; +import { DataViewManagerScopeName } from '../../data_view_manager/constants'; +import { useSourcererDataView } from '../../sourcerer/containers'; +import { useEnableExperimental } from '../../common/hooks/use_experimental_features'; +import { useFullDataView } from '../../data_view_manager/hooks/use_full_data_view'; export const DEFAULT_SEARCH_RESULTS_PER_PAGE = 10; export const TimelinesPage = React.memo(() => { const { tabName } = useParams<{ pageName: SecurityPageName; tabName: string }>(); - const { indicesExist } = useSourcererDataView(); + + const { newDataViewPickerEnabled } = useEnableExperimental(); + let { indicesExist } = useSourcererDataView(); + + const fullDataView = useFullDataView(DataViewManagerScopeName.default); + const experimentalIndicesExist = !!fullDataView?.matchedIndices.length; + + if (newDataViewPickerEnabled) { + indicesExist = experimentalIndicesExist; + } + const { timelinePrivileges: { crud: canWriteTimeline }, } = useUserPrivileges();