diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/index.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.tsx index 2ffb0670c4edc..6888efc40262b 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.tsx @@ -6,12 +6,7 @@ */ import { - EuiButton, - EuiCallOut, - EuiCheckbox, EuiComboBox, - EuiFlexGroup, - EuiFlexItem, EuiForm, EuiOutsideClickDetector, EuiPopover, @@ -19,7 +14,7 @@ import { EuiSpacer, EuiSuperSelect, } from '@elastic/eui'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { ChangeEventHandler, useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch } from 'react-redux'; import * as i18n from './translations'; @@ -27,12 +22,12 @@ import { sourcererActions, sourcererModel, sourcererSelectors } from '../../stor import { useDeepEqualSelector } from '../../hooks/use_selector'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { usePickIndexPatterns } from './use_pick_index_patterns'; -import { FormRow, PopoverContent, ResetButton, StyledButton, StyledFormRow } from './helpers'; +import { FormRow, PopoverContent, StyledButton, StyledFormRow } from './helpers'; import { TemporarySourcerer } from './temporary'; -import { UpdateDefaultDataViewModal } from './update_default_data_view_modal'; import { useSourcererDataView } from '../../containers/sourcerer'; import { useUpdateDataView } from './use_update_data_view'; import { Trigger } from './trigger'; +import { AlertsCheckbox, SaveButtons, SourcererCallout } from './sub_components'; interface SourcererComponentProps { scope: sourcererModel.SourcererScopeName; @@ -91,11 +86,12 @@ export const Sourcerer = React.memo(({ scope: scopeId } kibanaDataViews, missingPatterns, scopeId, + selectedDataViewId, selectedPatterns, signalIndexName, }); - const onCheckboxChanged = useCallback( + const onCheckboxChanged: ChangeEventHandler = useCallback( (e) => { setIsOnlyDetectionAlertsChecked(e.target.checked); setDataViewId(defaultDataView.id); @@ -251,49 +247,35 @@ export const Sourcerer = React.memo(({ scope: scopeId } <>{i18n.SELECT_DATA_VIEW} - {isOnlyDetectionAlerts && ( - - )} + - {isModified === 'deprecated' || isModified === 'missingPatterns' ? ( - <> - - setIsShowingUpdateModal(false)} - onContinue={onContinueUpdateDeprecated} - onUpdate={onUpdateDataView} - /> - + {(dataViewId === null && isModified === 'deprecated') || + isModified === 'missingPatterns' ? ( + setIsShowingUpdateModal(false)} + onReset={resetDataSources} + onUpdateStepOne={isModified === 'deprecated' ? onUpdateDeprecated : onUpdateDataView} + onUpdateStepTwo={onUpdateDataView} + selectedPatterns={selectedPatterns} + /> ) : ( <> - {isTimelineSourcerer && ( - - - - )} + {dataViewId && ( (({ scope: scopeId } /> - {!isDetectionsSourcerer && ( - - - - - {i18n.INDEX_PATTERNS_RESET} - - - - - {i18n.SAVE_INDEX_PATTERNS} - - - - - )} + diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/sub_components.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/sub_components.tsx new file mode 100644 index 0000000000000..4d10a880648f6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/sub_components.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { ChangeEventHandler } from 'react'; +import { EuiButton, EuiCallOut, EuiCheckbox, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { ResetButton, StyledFormRow } from './helpers'; +import * as i18n from './translations'; + +interface SourcererCalloutProps { + isOnlyDetectionAlerts: boolean; + title: string; +} + +export const SourcererCallout = React.memo( + ({ isOnlyDetectionAlerts, title }) => + isOnlyDetectionAlerts ? ( + + ) : null +); + +SourcererCallout.displayName = 'SourcererCallout'; + +interface AlertsCheckboxProps { + checked: boolean; + isShow: boolean; + onChange: ChangeEventHandler; +} + +export const AlertsCheckbox = React.memo(({ onChange, checked, isShow }) => + isShow ? ( + + + + ) : null +); + +AlertsCheckbox.displayName = 'AlertsCheckbox'; + +interface SaveButtonsProps { + disableSave: boolean; + isShow: boolean; + onReset: () => void; + onSave: () => void; +} + +export const SaveButtons = React.memo( + ({ disableSave, isShow, onReset, onSave }) => + isShow ? ( + + + + + {i18n.INDEX_PATTERNS_RESET} + + + + + {i18n.SAVE_INDEX_PATTERNS} + + + + + ) : null +); + +SaveButtons.displayName = 'SaveButtons'; diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/temporary.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/temporary.tsx index 36fae76c7739b..ec55b654b9fcc 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/temporary.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/temporary.tsx @@ -21,14 +21,15 @@ import { import React, { useMemo } from 'react'; import * as i18n from './translations'; import { Blockquote, ResetButton } from './helpers'; +import { UpdateDefaultDataViewModal } from './update_default_data_view_modal'; interface Props { activePatterns?: string[]; indicesExist: boolean; isModified: 'deprecated' | 'missingPatterns'; missingPatterns: string[]; - onClick: () => void; - onClose: () => void; + onDismiss: () => void; + onReset: () => void; onUpdate: () => void; selectedPatterns: string[]; } @@ -44,13 +45,13 @@ const translations = { }, }; -export const TemporarySourcerer = React.memo( +export const TemporarySourcererComp = React.memo( ({ activePatterns, indicesExist, isModified, - onClose, - onClick, + onDismiss, + onReset, onUpdate, selectedPatterns, missingPatterns, @@ -141,7 +142,7 @@ export const TemporarySourcerer = React.memo( id="xpack.securitySolution.indexPatterns.toggleToNewSourcerer" defaultMessage="We have preserved your timeline by creating a temporary data view. If you'd like to modify your data, we can recreate your temporary data view with the new data view selector. You can also manually select a data view {link}." values={{ - link: {i18n.TOGGLE_TO_NEW_SOURCERER}, + link: {i18n.TOGGLE_TO_NEW_SOURCERER}, }} /> )} @@ -158,7 +159,7 @@ export const TemporarySourcerer = React.memo( id="xpack.securitySolution.indexPatterns.missingPatterns.description" defaultMessage="We have preserved your timeline by creating a temporary data view. If you'd like to modify your data, we can add the missing index patterns to the Security Data View. You can also manually select a data view {link}." values={{ - link: {i18n.TOGGLE_TO_NEW_SOURCERER}, + link: {i18n.TOGGLE_TO_NEW_SOURCERER}, }} /> @@ -172,7 +173,7 @@ export const TemporarySourcerer = React.memo( aria-label={i18n.INDEX_PATTERNS_CLOSE} data-test-subj="sourcerer-deprecated-close" flush="left" - onClick={onClose} + onClick={onDismiss} title={i18n.INDEX_PATTERNS_CLOSE} > {i18n.INDEX_PATTERNS_CLOSE} @@ -185,4 +186,58 @@ export const TemporarySourcerer = React.memo( } ); +TemporarySourcererComp.displayName = 'TemporarySourcererComp'; + +interface TemporarySourcererProps { + activePatterns?: string[]; + indicesExist: boolean; + isModified: 'deprecated' | 'missingPatterns'; + isShowingUpdateModal: boolean; + missingPatterns: string[]; + onContinueWithoutUpdate: () => void; + onDismiss: () => void; + onDismissModal: () => void; + onReset: () => void; + onUpdateStepOne: () => void; + onUpdateStepTwo: () => void; + selectedPatterns: string[]; +} + +export const TemporarySourcerer = React.memo( + ({ + activePatterns, + indicesExist, + isModified, + missingPatterns, + onContinueWithoutUpdate, + onDismiss, + onReset, + onUpdateStepOne, + onUpdateStepTwo, + selectedPatterns, + isShowingUpdateModal, + onDismissModal, + }) => ( + <> + + + + ) +); + TemporarySourcerer.displayName = 'TemporarySourcerer'; diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/update_default_data_view_modal.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/update_default_data_view_modal.tsx index 78fc6f82fa748..29cace7f075de 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/update_default_data_view_modal.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/update_default_data_view_modal.tsx @@ -25,7 +25,7 @@ import { Blockquote, ResetButton } from './helpers'; interface Props { isShowing: boolean; missingPatterns: string[]; - onClose: () => void; + onDismissModal: () => void; onContinue: () => void; onUpdate: () => void; } @@ -41,9 +41,9 @@ const MyEuiModal = styled(EuiModal)` `; export const UpdateDefaultDataViewModal = React.memo( - ({ isShowing, onClose, onContinue, onUpdate, missingPatterns }) => + ({ isShowing, onDismissModal, onContinue, onUpdate, missingPatterns }) => isShowing ? ( - +

{i18n.UPDATE_SECURITY_DATA_VIEW}

diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/use_pick_index_patterns.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/use_pick_index_patterns.tsx index d7b094ab27b14..ae9990247f920 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/use_pick_index_patterns.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/use_pick_index_patterns.tsx @@ -19,6 +19,7 @@ interface UsePickIndexPatternsProps { kibanaDataViews: sourcererModel.SourcererModel['kibanaDataViews']; missingPatterns: string[]; scopeId: sourcererModel.SourcererScopeName; + selectedDataViewId: string | null; selectedPatterns: string[]; signalIndexName: string | null; } @@ -49,6 +50,7 @@ export const usePickIndexPatterns = ({ kibanaDataViews, missingPatterns, scopeId, + selectedDataViewId, selectedPatterns, signalIndexName, }: UsePickIndexPatternsProps): UsePickIndexPatterns => { @@ -155,11 +157,11 @@ export const usePickIndexPatterns = ({ // when scope updates, check modified to set/remove alerts label useEffect(() => { onSetIsModified( - selectedOptions.map(({ label }) => label), - dataViewId + selectedPatterns.map((pattern) => pattern), + selectedDataViewId ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dataViewId, missingPatterns, scopeId, selectedOptions]); + }, [selectedDataViewId, missingPatterns, scopeId, selectedPatterns]); const onChangeCombo = useCallback((newSelectedOptions) => { setSelectedOptions(newSelectedOptions); diff --git a/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx b/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx index a0178d45a9e07..696422dfc89db 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx @@ -39,8 +39,8 @@ const getEsFields = memoizeOne( export const useDataView = (): { indexFieldsSearch: (selectedDataViewId: string) => void } => { const { data } = useKibana().services; - const abortCtrl = useRef(new AbortController()); - const searchSubscription$ = useRef(new Subscription()); + const abortCtrl = useRef>({}); + const searchSubscription$ = useRef>({}); const dispatch = useDispatch(); const { addError, addWarning } = useAppToasts(); @@ -54,16 +54,19 @@ export const useDataView = (): { indexFieldsSearch: (selectedDataViewId: string) const indexFieldsSearch = useCallback( (selectedDataViewId: string) => { const asyncSearch = async () => { - abortCtrl.current = new AbortController(); + abortCtrl.current = { + ...abortCtrl.current, + [selectedDataViewId]: new AbortController(), + }; setLoading({ id: selectedDataViewId, loading: true }); - searchSubscription$.current = data.search + const subscription = data.search .search, IndexFieldsStrategyResponse>( { dataViewId: selectedDataViewId, onlyCheckIfIndicesExist: false, }, { - abortSignal: abortCtrl.current.signal, + abortSignal: abortCtrl.current[selectedDataViewId].signal, strategy: 'indexFields', } ) @@ -82,11 +85,15 @@ export const useDataView = (): { indexFieldsSearch: (selectedDataViewId: string) runtimeMappings: response.runtimeMappings, }) ); - searchSubscription$.current.unsubscribe(); + if (searchSubscription$.current[selectedDataViewId]) { + searchSubscription$.current[selectedDataViewId].unsubscribe(); + } } else if (isErrorResponse(response)) { setLoading({ id: selectedDataViewId, loading: false }); addWarning(i18n.ERROR_BEAT_FIELDS); - searchSubscription$.current.unsubscribe(); + if (searchSubscription$.current[selectedDataViewId]) { + searchSubscription$.current[selectedDataViewId].unsubscribe(); + } } }, error: (msg) => { @@ -98,12 +105,23 @@ export const useDataView = (): { indexFieldsSearch: (selectedDataViewId: string) addError(msg, { title: i18n.FAIL_BEAT_FIELDS, }); - searchSubscription$.current.unsubscribe(); + if (searchSubscription$.current[selectedDataViewId]) { + searchSubscription$.current[selectedDataViewId].unsubscribe(); + } }, }); + searchSubscription$.current = { + ...searchSubscription$.current, + [selectedDataViewId]: subscription, + }; }; - searchSubscription$.current.unsubscribe(); - abortCtrl.current.abort(); + if (searchSubscription$.current[selectedDataViewId] != null) { + searchSubscription$.current[selectedDataViewId].unsubscribe(); + } + + if (abortCtrl.current[selectedDataViewId] != null) { + abortCtrl.current[selectedDataViewId].abort(); + } asyncSearch(); }, [addError, addWarning, data.search, dispatch, setLoading] @@ -111,8 +129,10 @@ export const useDataView = (): { indexFieldsSearch: (selectedDataViewId: string) useEffect(() => { return () => { - searchSubscription$.current.unsubscribe(); - abortCtrl.current.abort(); + Object.values(searchSubscription$.current).forEach((subscription) => + subscription.unsubscribe() + ); + Object.values(abortCtrl.current).forEach((signal) => signal.abort()); }; }, []); diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx index 2e060973c431f..5a00fb8d986d5 100644 --- a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx @@ -52,13 +52,15 @@ jest.mock('../../utils/route/use_route_spy', () => ({ useRouteSpy: () => [mockRouteSpy], })); +const mockSearch = jest.fn(); + jest.mock('../../lib/kibana', () => ({ - useToasts: jest.fn().mockReturnValue({ + useToasts: () => ({ addError: jest.fn(), addSuccess: jest.fn(), addWarning: jest.fn(), }), - useKibana: jest.fn().mockReturnValue({ + useKibana: () => ({ services: { application: { capabilities: { @@ -72,7 +74,7 @@ jest.mock('../../lib/kibana', () => ({ getTitles: jest.fn().mockImplementation(() => Promise.resolve(mockPatterns)), }, search: { - search: jest.fn().mockImplementation(() => ({ + search: mockSearch.mockImplementation(() => ({ subscribe: jest.fn().mockImplementation(() => ({ error: jest.fn(), next: jest.fn(), @@ -188,6 +190,8 @@ describe('Sourcerer Hooks', () => { type: 'x-pack/security_solution/local/sourcerer/SET_SOURCERER_SCOPE_LOADING', payload: { loading: false }, }); + expect(mockDispatch).toHaveBeenCalledTimes(7); + expect(mockSearch).toHaveBeenCalledTimes(2); }); }); }); @@ -216,6 +220,48 @@ describe('Sourcerer Hooks', () => { }); }); }); + it('index field search is not repeated when default and timeline have same dataViewId', async () => { + await act(async () => { + const { rerender, waitForNextUpdate } = renderHook(() => useInitSourcerer(), { + wrapper: ({ children }) => {children}, + }); + await waitForNextUpdate(); + rerender(); + await waitFor(() => { + expect(mockSearch).toHaveBeenCalledTimes(1); + }); + }); + }); + it('index field search called twice when default and timeline have different dataViewId', async () => { + store = createStore( + { + ...mockGlobalState, + sourcerer: { + ...mockGlobalState.sourcerer, + sourcererScopes: { + ...mockGlobalState.sourcerer.sourcererScopes, + [SourcererScopeName.timeline]: { + ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline], + selectedDataViewId: 'different-id', + }, + }, + }, + }, + SUB_PLUGINS_REDUCER, + kibanaObservable, + storage + ); + await act(async () => { + const { rerender, waitForNextUpdate } = renderHook(() => useInitSourcerer(), { + wrapper: ({ children }) => {children}, + }); + await waitForNextUpdate(); + rerender(); + await waitFor(() => { + expect(mockSearch).toHaveBeenCalledTimes(2); + }); + }); + }); describe('useSourcererDataView', () => { it('Should exclude elastic cloud alias when selected patterns include "logs-*" as an alias', async () => { diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx index c493cb528d09a..f0872d5cebf4c 100644 --- a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx @@ -84,8 +84,15 @@ export const useInitSourcerer = ( ); const { indexFieldsSearch } = useDataView(); + const searchedIds = useRef([]); useEffect( - () => activeDataViewIds.forEach((id) => id != null && id.length > 0 && indexFieldsSearch(id)), + () => + activeDataViewIds.forEach((id) => { + if (id != null && id.length > 0 && !searchedIds.current.includes(id)) { + searchedIds.current = [...searchedIds.current, id]; + indexFieldsSearch(id); + } + }), [activeDataViewIds, indexFieldsSearch] ); @@ -180,28 +187,33 @@ export const useInitSourcerer = ( }, [defaultDataView.title, dispatch, indexFieldsSearch, addError] ); - useEffect(() => { + + const onSignalIndexUpdated = useCallback(() => { if ( !loadingSignalIndex && signalIndexName != null && signalIndexNameSourcerer == null && defaultDataView.id.length > 0 ) { - // update signal name also updates sourcerer - // we hit this the first time signal index is created updateSourcererDataView(signalIndexName); dispatch(sourcererActions.setSignalIndexName({ signalIndexName })); } }, [ - defaultDataView.id, + defaultDataView.id.length, dispatch, - indexFieldsSearch, - isSignalIndexExists, loadingSignalIndex, signalIndexName, signalIndexNameSourcerer, updateSourcererDataView, ]); + + useEffect(() => { + onSignalIndexUpdated(); + // because we only want onSignalIndexUpdated to run when signalIndexName updates, + // but we want to know about the updates from the dependencies of onSignalIndexUpdated + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signalIndexName]); + // Related to the detection page useEffect(() => { if ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.tsx index 42f9801057190..70702bcb8c653 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.tsx @@ -282,13 +282,13 @@ export const EqlTabContentComponent: React.FC = ({ setFullScreen={setTimelineFullScreen} /> )} - + - + {activeTab === TimelineTabs.eql && ( )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx index 1c975c6cba8e3..707bc591de577 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx @@ -120,9 +120,14 @@ const DatePicker = styled(EuiFlexItem)` width: auto; } `; - DatePicker.displayName = 'DatePicker'; +const SourcererFlex = styled(EuiFlexItem)` + align-items: flex-end; +`; + +SourcererFlex.displayName = 'SourcererFlex'; + const VerticalRule = styled.div` width: 2px; height: 100%; @@ -355,7 +360,7 @@ export const QueryTabContentComponent: React.FC = ({ setFullScreen={setTimelineFullScreen} /> )} - + = ({ - + {activeTab === TimelineTabs.query && ( )} - +