diff --git a/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx b/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx index 0ad71d9a23cc..89230ae03a92 100644 --- a/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx +++ b/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx @@ -50,6 +50,13 @@ const title = i18n.translate('indexPatternManagement.dataViewTable.title', { defaultMessage: 'Data views', }); +const securityDataView = i18n.translate( + 'indexPatternManagement.indexPatternTable.badge.securityDataViewTitle', + { + defaultMessage: 'Security Data View', + } +); + interface Props extends RouteComponentProps { canSave: boolean; showCreateDialog?: boolean; @@ -116,6 +123,10 @@ export const IndexPatternTable = ({   + {index.id && index.id === 'security-solution' && ( + {securityDataView} + )} + {index.tags && index.tags.map(({ key: tagKey, name: tagName }) => ( {tagName} diff --git a/x-pack/plugins/security_solution/public/app/home/global_header/index.test.tsx b/x-pack/plugins/security_solution/public/app/home/global_header/index.test.tsx new file mode 100644 index 000000000000..c16e77e9182f --- /dev/null +++ b/x-pack/plugins/security_solution/public/app/home/global_header/index.test.tsx @@ -0,0 +1,121 @@ +/* + * 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 } from '@testing-library/react'; +import { useLocation } from 'react-router-dom'; +import { GlobalHeader } from '.'; +import { SecurityPageName } from '../../../../common/constants'; +import { + createSecuritySolutionStorageMock, + mockGlobalState, + SUB_PLUGINS_REDUCER, + TestProviders, +} from '../../../common/mock'; +import { TimelineId } from '../../../../common/types/timeline'; +import { createStore } from '../../../common/store'; +import { kibanaObservable } from '../../../../../timelines/public/mock'; +import { sourcererPaths } from '../../../common/containers/sourcerer'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +jest.mock('../../../common/lib/kibana', () => { + const originalModule = jest.requireActual('../../../common/lib/kibana'); + return { + ...originalModule, + useKibana: jest + .fn() + .mockReturnValue({ services: { http: { basePath: { prepend: jest.fn() } } } }), + useUiSetting$: jest.fn().mockReturnValue([]), + }; +}); + +jest.mock('react-reverse-portal', () => ({ + InPortal: ({ children }: { children: React.ReactNode }) => <>{children}, + OutPortal: ({ children }: { children: React.ReactNode }) => <>{children}, + createPortalNode: () => ({ unmount: jest.fn() }), +})); + +describe('global header', () => { + const mockSetHeaderActionMenu = jest.fn(); + const state = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById.test, + show: false, + }, + }, + }, + }; + const { storage } = createSecuritySolutionStorageMock(); + const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + + it('has add data link', () => { + (useLocation as jest.Mock).mockReturnValue([ + { pageName: SecurityPageName.overview, detailName: undefined }, + ]); + const { getByText } = render( + + + + ); + expect(getByText('Add integrations')).toBeInTheDocument(); + }); + + it.each(sourcererPaths)('shows sourcerer on %s page', (pathname) => { + (useLocation as jest.Mock).mockReturnValue({ pathname }); + + const { getByTestId } = render( + + + + ); + expect(getByTestId('sourcerer-trigger')).toBeInTheDocument(); + }); + + it('shows sourcerer on rule details page', () => { + (useLocation as jest.Mock).mockReturnValue({ pathname: sourcererPaths[2] }); + + const { getByTestId } = render( + + + + ); + expect(getByTestId('sourcerer-trigger')).toBeInTheDocument(); + }); + + it('shows no sourcerer if timeline is open', () => { + const mockstate = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById.test, + show: true, + }, + }, + }, + }; + const mockStore = createStore(mockstate, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + + (useLocation as jest.Mock).mockReturnValue({ pathname: sourcererPaths[2] }); + + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('sourcerer-trigger')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx b/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx index 41e441fd4110..6afcc649da5f 100644 --- a/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx @@ -5,14 +5,14 @@ * 2.0. */ import { - EuiHeaderSection, - EuiHeaderLinks, EuiHeaderLink, + EuiHeaderLinks, + EuiHeaderSection, EuiHeaderSectionItem, } from '@elastic/eui'; import React, { useEffect, useMemo } from 'react'; import { useLocation } from 'react-router-dom'; -import { createPortalNode, OutPortal, InPortal } from 'react-reverse-portal'; +import { createPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; import { i18n } from '@kbn/i18n'; import { AppMountParameters } from '../../../../../../../src/core/public'; @@ -21,6 +21,12 @@ import { MlPopover } from '../../../common/components/ml_popover/ml_popover'; import { useKibana } from '../../../common/lib/kibana'; import { ADD_DATA_PATH } from '../../../../common/constants'; import { isDetectionsPath } from '../../../../public/helpers'; +import { Sourcerer } from '../../../common/components/sourcerer'; +import { TimelineId } from '../../../../common/types/timeline'; +import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; +import { timelineSelectors } from '../../../timelines/store/timeline'; +import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { getScopeFromPath, showSourcererByPath } from '../../../common/containers/sourcerer'; const BUTTON_ADD_DATA = i18n.translate('xpack.securitySolution.globalHeader.buttonAddData', { defaultMessage: 'Add integrations', @@ -40,6 +46,16 @@ export const GlobalHeader = React.memo( } = useKibana().services; const { pathname } = useLocation(); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const showTimeline = useShallowEqualSelector( + (state) => (getTimeline(state, TimelineId.active) ?? timelineDefaults).show + ); + + const sourcererScope = getScopeFromPath(pathname); + const showSourcerer = showSourcererByPath(pathname); + + const href = useMemo(() => prepend(ADD_DATA_PATH), [prepend]); + useEffect(() => { setHeaderActionMenu((element) => { const mount = toMountPoint(); @@ -65,11 +81,14 @@ export const GlobalHeader = React.memo( {BUTTON_ADD_DATA} + {showSourcerer && !showTimeline && ( + + )} diff --git a/x-pack/plugins/security_solution/public/cases/components/case_header_page/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_header_page/index.tsx index 53bc20af5e49..c1eb11ea5182 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_header_page/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_header_page/index.tsx @@ -9,8 +9,6 @@ import React from 'react'; import { HeaderPage, HeaderPageProps } from '../../../common/components/header_page'; -const CaseHeaderPageComponent: React.FC = (props) => ( - -); +const CaseHeaderPageComponent: React.FC = (props) => ; export const CaseHeaderPage = React.memo(CaseHeaderPageComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/header_page/__snapshots__/index.test.tsx.snap index d00bd7040c16..9e5b265c187c 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/header_page/__snapshots__/index.test.tsx.snap @@ -33,9 +33,6 @@ exports[`HeaderPage it renders 1`] = ` Test supplement

- = ({ border, children, draggableArguments, - hideSourcerer = false, isLoading, - sourcererScope = SourcererScopeName.default, subtitle, subtitle2, title, @@ -149,7 +143,6 @@ const HeaderPageComponent: React.FC = ({ {children} )} - {!hideSourcerer && } {/* Manually add a 'padding-bottom' to header */} diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/helpers.tsx new file mode 100644 index 000000000000..af21a018ee47 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/helpers.tsx @@ -0,0 +1,126 @@ +/* + * 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 { + EuiSuperSelectOption, + EuiIcon, + EuiBadge, + EuiButtonEmpty, + EuiFormRow, + EuiFormRowProps, +} from '@elastic/eui'; +import styled from 'styled-components'; + +import { sourcererModel } from '../../store/sourcerer'; + +import * as i18n from './translations'; + +export const FormRow = styled(EuiFormRow)` + display: ${({ $expandAdvancedOptions }) => ($expandAdvancedOptions ? 'flex' : 'none')}; + max-width: none; +`; + +export const StyledFormRow = styled(EuiFormRow)` + max-width: none; +`; + +export const StyledButton = styled(EuiButtonEmpty)` + &:enabled:focus, + &:focus { + background-color: transparent; + } +`; + +export const ResetButton = styled(EuiButtonEmpty)` + width: fit-content; + &:enabled:focus, + &:focus { + background-color: transparent; + } +`; + +export const PopoverContent = styled.div` + width: 600px; +`; + +export const StyledBadge = styled(EuiBadge)` + margin-left: 8px; +`; + +interface GetDataViewSelectOptionsProps { + dataViewId: string; + defaultDataView: sourcererModel.KibanaDataView; + isModified: boolean; + isOnlyDetectionAlerts: boolean; + kibanaDataViews: sourcererModel.KibanaDataView[]; +} + +export const getDataViewSelectOptions = ({ + dataViewId, + defaultDataView, + isModified, + isOnlyDetectionAlerts, + kibanaDataViews, +}: GetDataViewSelectOptionsProps): Array> => + isOnlyDetectionAlerts + ? [ + { + inputDisplay: ( + + {i18n.SIEM_SECURITY_DATA_VIEW_LABEL} + + {i18n.ALERTS_BADGE_TITLE} + + + ), + value: defaultDataView.id, + }, + ] + : kibanaDataViews.map(({ title, id }) => ({ + inputDisplay: + id === defaultDataView.id ? ( + + {i18n.SECURITY_DEFAULT_DATA_VIEW_LABEL} + {isModified && id === dataViewId && ( + {i18n.MODIFIED_BADGE_TITLE} + )} + + ) : ( + + {title} + {isModified && id === dataViewId && ( + {i18n.MODIFIED_BADGE_TITLE} + )} + + ), + value: id, + })); + +interface GetTooltipContent { + isOnlyDetectionAlerts: boolean; + isPopoverOpen: boolean; + selectedPatterns: string[]; + signalIndexName: string | null; +} + +export const getTooltipContent = ({ + isOnlyDetectionAlerts, + isPopoverOpen, + selectedPatterns, + signalIndexName, +}: GetTooltipContent): string | null => { + if (isPopoverOpen || (isOnlyDetectionAlerts && !signalIndexName)) { + return null; + } + return (isOnlyDetectionAlerts ? [signalIndexName] : selectedPatterns).join(', '); +}; + +export const getPatternListWithoutSignals = ( + patternList: string[], + signalIndexName: string | null +): string[] => patternList.filter((p) => p !== signalIndexName); diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx index 1b23d23c5eb6..c2da7e78d64e 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx @@ -17,7 +17,7 @@ import { SUB_PLUGINS_REDUCER, TestProviders, } from '../../mock'; -import { createStore, State } from '../../store'; +import { createStore } from '../../store'; import { EuiSuperSelectOption } from '@elastic/eui/src/components/form/super_select/super_select_control'; const mockDispatch = jest.fn(); @@ -45,31 +45,55 @@ const defaultProps = { scope: sourcererModel.SourcererScopeName.default, }; -describe('Sourcerer component', () => { - const state: State = mockGlobalState; - const { id, patternList, title } = state.sourcerer.defaultDataView; - const patternListNoSignals = patternList - .filter((p) => p !== state.sourcerer.signalIndexName) - .sort(); - const checkOptionsAndSelections = (wrapper: ReactWrapper, patterns: string[]) => ({ - availableOptionCount: wrapper.find(`[data-test-subj="sourcerer-combo-option"]`).length, - optionsSelected: patterns.every((pattern) => - wrapper - .find(`[data-test-subj="sourcerer-combo-box"] span[title="${pattern}"]`) - .first() - .exists() - ), - }); +const checkOptionsAndSelections = (wrapper: ReactWrapper, patterns: string[]) => ({ + availableOptionCount: wrapper.find(`[data-test-subj="sourcerer-combo-option"]`).length, + optionsSelected: patterns.every((pattern) => + wrapper.find(`[data-test-subj="sourcerer-combo-box"] span[title="${pattern}"]`).first().exists() + ), +}); +const { id, patternList, title } = mockGlobalState.sourcerer.defaultDataView; +const patternListNoSignals = patternList + .filter((p) => p !== mockGlobalState.sourcerer.signalIndexName) + .sort(); +let store: ReturnType; +describe('Sourcerer component', () => { const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); jest.clearAllMocks(); jest.restoreAllMocks(); }); + it('renders data view title', () => { + const wrapper = mount( + + + + ); + wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click'); + expect(wrapper.find(`[data-test-subj="sourcerer-title"]`).first().text()).toEqual( + 'Data view selection' + ); + }); + + it('renders a toggle for advanced options', () => { + const testProps = { + ...defaultProps, + showAlertsOnlyCheckbox: true, + }; + const wrapper = mount( + + + + ); + wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click'); + expect( + wrapper.find(`[data-test-subj="sourcerer-advanced-options-toggle"]`).first().text() + ).toEqual('Advanced options'); + }); + it('renders tooltip', () => { const wrapper = mount( @@ -119,25 +143,25 @@ describe('Sourcerer component', () => { it('Removes duplicate options from title', () => { store = createStore( { - ...state, + ...mockGlobalState, sourcerer: { - ...state.sourcerer, + ...mockGlobalState.sourcerer, defaultDataView: { - ...state.sourcerer.defaultDataView, + ...mockGlobalState.sourcerer.defaultDataView, id: '1234', title: 'filebeat-*,auditbeat-*,auditbeat-*,auditbeat-*,auditbeat-*', patternList: ['filebeat-*', 'auditbeat-*'], }, kibanaDataViews: [ { - ...state.sourcerer.defaultDataView, + ...mockGlobalState.sourcerer.defaultDataView, id: '1234', title: 'filebeat-*,auditbeat-*,auditbeat-*,auditbeat-*,auditbeat-*', patternList: ['filebeat-*', 'auditbeat-*'], }, ], sourcererScopes: { - ...state.sourcerer.sourcererScopes, + ...mockGlobalState.sourcerer.sourcererScopes, [SourcererScopeName.default]: { ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.default], loading: false, @@ -170,25 +194,25 @@ describe('Sourcerer component', () => { it('Disables options with no data', () => { store = createStore( { - ...state, + ...mockGlobalState, sourcerer: { - ...state.sourcerer, + ...mockGlobalState.sourcerer, defaultDataView: { - ...state.sourcerer.defaultDataView, + ...mockGlobalState.sourcerer.defaultDataView, id: '1234', title: 'filebeat-*,auditbeat-*,fakebeat-*', patternList: ['filebeat-*', 'auditbeat-*'], }, kibanaDataViews: [ { - ...state.sourcerer.defaultDataView, + ...mockGlobalState.sourcerer.defaultDataView, id: '1234', title: 'filebeat-*,auditbeat-*,fakebeat-*', patternList: ['filebeat-*', 'auditbeat-*'], }, ], sourcererScopes: { - ...state.sourcerer.sourcererScopes, + ...mockGlobalState.sourcerer.sourcererScopes, [SourcererScopeName.default]: { ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.default], loading: false, @@ -225,15 +249,15 @@ describe('Sourcerer component', () => { sourcerer: { ...mockGlobalState.sourcerer, kibanaDataViews: [ - state.sourcerer.defaultDataView, + mockGlobalState.sourcerer.defaultDataView, { - ...state.sourcerer.defaultDataView, + ...mockGlobalState.sourcerer.defaultDataView, id: '1234', title: 'auditbeat-*', patternList: ['auditbeat-*'], }, { - ...state.sourcerer.defaultDataView, + ...mockGlobalState.sourcerer.defaultDataView, id: '12347', title: 'packetbeat-*', patternList: ['packetbeat-*'], @@ -244,9 +268,8 @@ describe('Sourcerer component', () => { [SourcererScopeName.default]: { ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.default], loading: false, - patternList, selectedDataViewId: id, - selectedPatterns: patternList.slice(0, 2), + selectedPatterns: patternListNoSignals.slice(0, 2), }, }, }, @@ -260,7 +283,7 @@ describe('Sourcerer component', () => { ); wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click'); wrapper.find(`[data-test-subj="comboBoxInput"]`).first().simulate('click'); - expect(checkOptionsAndSelections(wrapper, patternList.slice(0, 2))).toEqual({ + expect(checkOptionsAndSelections(wrapper, patternListNoSignals.slice(0, 2))).toEqual({ // should hide signal index availableOptionCount: title.split(',').length - 3, optionsSelected: true, @@ -272,15 +295,15 @@ describe('Sourcerer component', () => { sourcerer: { ...mockGlobalState.sourcerer, kibanaDataViews: [ - state.sourcerer.defaultDataView, + mockGlobalState.sourcerer.defaultDataView, { - ...state.sourcerer.defaultDataView, + ...mockGlobalState.sourcerer.defaultDataView, id: '1234', title: 'auditbeat-*', patternList: ['auditbeat-*'], }, { - ...state.sourcerer.defaultDataView, + ...mockGlobalState.sourcerer.defaultDataView, id: '12347', title: 'packetbeat-*', patternList: ['packetbeat-*'], @@ -305,7 +328,7 @@ describe('Sourcerer component', () => { ); - wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); wrapper.find(`[data-test-subj="comboBoxInput"]`).first().simulate('click'); expect(checkOptionsAndSelections(wrapper, patternList.slice(0, 2))).toEqual({ // should show every option except fakebeat-* @@ -316,25 +339,25 @@ describe('Sourcerer component', () => { it('onSave dispatches setSelectedDataView', async () => { store = createStore( { - ...state, + ...mockGlobalState, sourcerer: { - ...state.sourcerer, + ...mockGlobalState.sourcerer, kibanaDataViews: [ - state.sourcerer.defaultDataView, + mockGlobalState.sourcerer.defaultDataView, { - ...state.sourcerer.defaultDataView, + ...mockGlobalState.sourcerer.defaultDataView, id: '1234', title: 'filebeat-*', patternList: ['filebeat-*'], }, ], sourcererScopes: { - ...state.sourcerer.sourcererScopes, + ...mockGlobalState.sourcerer.sourcererScopes, [SourcererScopeName.default]: { ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.default], loading: false, selectedDataViewId: id, - selectedPatterns: patternList.slice(0, 2), + selectedPatterns: patternListNoSignals.slice(0, 2), }, }, }, @@ -350,13 +373,12 @@ describe('Sourcerer component', () => { ); wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click'); wrapper.find(`[data-test-subj="comboBoxInput"]`).first().simulate('click'); - expect(checkOptionsAndSelections(wrapper, patternList.slice(0, 2))).toEqual({ + expect(checkOptionsAndSelections(wrapper, patternListNoSignals.slice(0, 2))).toEqual({ availableOptionCount: title.split(',').length - 3, optionsSelected: true, }); - wrapper.find(`[data-test-subj="sourcerer-combo-option"]`).first().simulate('click'); - expect(checkOptionsAndSelections(wrapper, patternList.slice(0, 3))).toEqual({ + expect(checkOptionsAndSelections(wrapper, patternListNoSignals.slice(0, 3))).toEqual({ availableOptionCount: title.split(',').length - 4, optionsSelected: true, }); @@ -367,7 +389,7 @@ describe('Sourcerer component', () => { sourcererActions.setSelectedDataView({ id: SourcererScopeName.default, selectedDataViewId: id, - selectedPatterns: patternList.slice(0, 3), + selectedPatterns: patternListNoSignals.slice(0, 3), }) ); }); @@ -387,7 +409,7 @@ describe('Sourcerer component', () => { wrapper .find( - `[data-test-subj="sourcerer-combo-box"] [title="${patternList[0]}"] button.euiBadge__iconButton` + `[data-test-subj="sourcerer-combo-box"] [title="${patternListNoSignals[0]}"] button.euiBadge__iconButton` ) .first() .simulate('click'); @@ -407,13 +429,13 @@ describe('Sourcerer component', () => { it('disables saving when no index patterns are selected', () => { store = createStore( { - ...state, + ...mockGlobalState, sourcerer: { - ...state.sourcerer, + ...mockGlobalState.sourcerer, kibanaDataViews: [ - state.sourcerer.defaultDataView, + mockGlobalState.sourcerer.defaultDataView, { - ...state.sourcerer.defaultDataView, + ...mockGlobalState.sourcerer.defaultDataView, id: '1234', title: 'auditbeat-*', patternList: ['auditbeat-*'], @@ -434,72 +456,21 @@ describe('Sourcerer component', () => { wrapper.find('[data-test-subj="comboBoxClearButton"]').first().simulate('click'); expect(wrapper.find('[data-test-subj="sourcerer-save"]').first().prop('disabled')).toBeTruthy(); }); - it('Selects a different index pattern', async () => { - const state2 = { - ...mockGlobalState, - sourcerer: { - ...mockGlobalState.sourcerer, - kibanaDataViews: [ - state.sourcerer.defaultDataView, - { - ...state.sourcerer.defaultDataView, - id: '1234', - title: 'fakebeat-*,neatbeat-*', - patternList: ['fakebeat-*'], - }, - ], - sourcererScopes: { - ...mockGlobalState.sourcerer.sourcererScopes, - [SourcererScopeName.default]: { - ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.default], - loading: false, - patternList, - selectedDataViewId: id, - selectedPatterns: patternList.slice(0, 2), - }, - }, - }, - }; - - store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - const wrapper = mount( - - - - ); - wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click'); - wrapper.find(`button[data-test-subj="sourcerer-select"]`).first().simulate('click'); - - wrapper.find(`[data-test-subj="dataView-option-super"]`).first().simulate('click'); - expect(checkOptionsAndSelections(wrapper, ['fakebeat-*'])).toEqual({ - availableOptionCount: 0, - optionsSelected: true, - }); - wrapper.find(`[data-test-subj="sourcerer-save"]`).first().simulate('click'); - - expect(mockDispatch).toHaveBeenCalledWith( - sourcererActions.setSelectedDataView({ - id: SourcererScopeName.default, - selectedDataViewId: '1234', - selectedPatterns: ['fakebeat-*'], - }) - ); - }); it('Does display signals index on timeline sourcerer', () => { const state2 = { ...mockGlobalState, sourcerer: { ...mockGlobalState.sourcerer, kibanaDataViews: [ - state.sourcerer.defaultDataView, + mockGlobalState.sourcerer.defaultDataView, { - ...state.sourcerer.defaultDataView, + ...mockGlobalState.sourcerer.defaultDataView, id: '1234', title: 'auditbeat-*', patternList: ['auditbeat-*'], }, { - ...state.sourcerer.defaultDataView, + ...mockGlobalState.sourcerer.defaultDataView, id: '12347', title: 'packetbeat-*', patternList: ['packetbeat-*'], @@ -510,9 +481,8 @@ describe('Sourcerer component', () => { [SourcererScopeName.timeline]: { ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline], loading: false, - patternList, selectedDataViewId: id, - selectedPatterns: patternList.slice(0, 2), + selectedPatterns: patternListNoSignals.slice(0, 2), }, }, }, @@ -524,9 +494,9 @@ describe('Sourcerer component', () => { ); - wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); wrapper.find(`[data-test-subj="comboBoxToggleListButton"]`).first().simulate('click'); - expect(wrapper.find(`[data-test-subj="sourcerer-combo-option"]`).at(6).text()).toEqual( + expect(wrapper.find(`[data-test-subj="sourcerer-combo-option"]`).at(0).text()).toEqual( mockGlobalState.sourcerer.signalIndexName ); }); @@ -536,15 +506,15 @@ describe('Sourcerer component', () => { sourcerer: { ...mockGlobalState.sourcerer, kibanaDataViews: [ - state.sourcerer.defaultDataView, + mockGlobalState.sourcerer.defaultDataView, { - ...state.sourcerer.defaultDataView, + ...mockGlobalState.sourcerer.defaultDataView, id: '1234', title: 'auditbeat-*', patternList: ['auditbeat-*'], }, { - ...state.sourcerer.defaultDataView, + ...mockGlobalState.sourcerer.defaultDataView, id: '12347', title: 'packetbeat-*', patternList: ['packetbeat-*'], @@ -555,9 +525,8 @@ describe('Sourcerer component', () => { [SourcererScopeName.default]: { ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.default], loading: false, - patternList, selectedDataViewId: id, - selectedPatterns: patternList.slice(0, 2), + selectedPatterns: patternListNoSignals.slice(0, 2), }, }, }, @@ -581,3 +550,204 @@ describe('Sourcerer component', () => { ).toBeFalsy(); }); }); + +describe('sourcerer on alerts page or rules details page', () => { + let wrapper: ReactWrapper; + const { storage } = createSecuritySolutionStorageMock(); + store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + const testProps = { + scope: sourcererModel.SourcererScopeName.detections, + }; + + beforeAll(() => { + wrapper = mount( + + + + ); + wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="sourcerer-advanced-options-toggle"]`).first().simulate('click'); + }); + + it('renders an alerts badge in sourcerer button', () => { + expect(wrapper.find(`[data-test-subj="sourcerer-alerts-badge"]`).first().text()).toEqual( + 'Alerts' + ); + }); + + it('renders a callout', () => { + expect(wrapper.find(`[data-test-subj="sourcerer-callout"]`).first().text()).toEqual( + 'Data view cannot be modified on this page' + ); + }); + + it('disable data view selector', () => { + expect( + wrapper.find(`[data-test-subj="sourcerer-select"]`).first().prop('disabled') + ).toBeTruthy(); + }); + + it('data view selector is default to Security Data View', () => { + expect( + wrapper.find(`[data-test-subj="sourcerer-select"]`).first().prop('valueOfSelected') + ).toEqual('security-solution'); + }); + + it('renders an alert badge in data view selector', () => { + expect(wrapper.find(`[data-test-subj="security-alerts-option-badge"]`).first().text()).toEqual( + 'Alerts' + ); + }); + + it('disable index pattern selector', () => { + expect( + wrapper.find(`[data-test-subj="sourcerer-combo-box"]`).first().prop('disabled') + ).toBeTruthy(); + }); + + it('shows signal index as index pattern option', () => { + expect(wrapper.find(`[data-test-subj="sourcerer-combo-box"]`).first().prop('options')).toEqual([ + { disabled: false, label: '.siem-signals-spacename', value: '.siem-signals-spacename' }, + ]); + }); + + it('does not render reset button', () => { + expect(wrapper.find(`[data-test-subj="sourcerer-reset"]`).exists()).toBeFalsy(); + }); + + it('does not render save button', () => { + expect(wrapper.find(`[data-test-subj="sourcerer-save"]`).exists()).toBeFalsy(); + }); +}); + +describe('timeline sourcerer', () => { + let wrapper: ReactWrapper; + const { storage } = createSecuritySolutionStorageMock(); + store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + const testProps = { + scope: sourcererModel.SourcererScopeName.timeline, + }; + + beforeAll(() => { + wrapper = mount( + + + + ); + wrapper.find(`[data-test-subj="timeline-sourcerer-trigger"]`).first().simulate('click'); + wrapper + .find( + `[data-test-subj="timeline-sourcerer-popover"] [data-test-subj="sourcerer-advanced-options-toggle"]` + ) + .first() + .simulate('click'); + }); + + it('renders "alerts only" checkbox', () => { + wrapper + .find( + `[data-test-subj="timeline-sourcerer-popover"] [data-test-subj="sourcerer-alert-only-checkbox"]` + ) + .first() + .simulate('click'); + expect(wrapper.find(`[data-test-subj="sourcerer-alert-only-checkbox"]`).first().text()).toEqual( + 'Show only detection alerts' + ); + }); + + it('data view selector is enabled', () => { + expect( + wrapper + .find(`[data-test-subj="timeline-sourcerer-popover"] [data-test-subj="sourcerer-select"]`) + .first() + .prop('disabled') + ).toBeFalsy(); + }); + + it('data view selector is default to Security Default Data View', () => { + expect( + wrapper + .find(`[data-test-subj="timeline-sourcerer-popover"] [data-test-subj="sourcerer-select"]`) + .first() + .prop('valueOfSelected') + ).toEqual('security-solution'); + }); + + it('index pattern selector is enabled', () => { + expect( + wrapper + .find( + `[data-test-subj="timeline-sourcerer-popover"] [data-test-subj="sourcerer-combo-box"]` + ) + .first() + .prop('disabled') + ).toBeFalsy(); + }); + + it('render reset button', () => { + expect(wrapper.find(`[data-test-subj="sourcerer-reset"]`).exists()).toBeTruthy(); + }); + + it('render save button', () => { + expect(wrapper.find(`[data-test-subj="sourcerer-save"]`).exists()).toBeTruthy(); + }); +}); + +describe('Sourcerer integration tests', () => { + const state = { + ...mockGlobalState, + sourcerer: { + ...mockGlobalState.sourcerer, + kibanaDataViews: [ + mockGlobalState.sourcerer.defaultDataView, + { + ...mockGlobalState.sourcerer.defaultDataView, + id: '1234', + title: 'fakebeat-*,neatbeat-*', + patternList: ['fakebeat-*'], + }, + ], + sourcererScopes: { + ...mockGlobalState.sourcerer.sourcererScopes, + [SourcererScopeName.default]: { + ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.default], + loading: false, + selectedDataViewId: id, + selectedPatterns: patternListNoSignals.slice(0, 2), + }, + }, + }, + }; + + const { storage } = createSecuritySolutionStorageMock(); + + beforeEach(() => { + store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + it('Selects a different index pattern', async () => { + const wrapper = mount( + + + + ); + wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click'); + wrapper.find(`button[data-test-subj="sourcerer-select"]`).first().simulate('click'); + + wrapper.find(`[data-test-subj="dataView-option-super"]`).first().simulate('click'); + expect(checkOptionsAndSelections(wrapper, ['fakebeat-*'])).toEqual({ + availableOptionCount: 0, + optionsSelected: true, + }); + wrapper.find(`[data-test-subj="sourcerer-save"]`).first().simulate('click'); + + expect(mockDispatch).toHaveBeenCalledWith( + sourcererActions.setSelectedDataView({ + id: SourcererScopeName.default, + selectedDataViewId: '1234', + selectedPatterns: ['fakebeat-*'], + }) + ); + }); +}); 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 6f32282c5304..6f223cbb4aa3 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 @@ -7,48 +7,52 @@ import { EuiButton, - EuiButtonEmpty, + EuiCallOut, + EuiCheckbox, EuiComboBox, - EuiComboBoxOptionOption, EuiFlexGroup, EuiFlexItem, - EuiIcon, + EuiForm, EuiPopover, EuiPopoverTitle, EuiSpacer, EuiSuperSelect, - EuiText, EuiToolTip, } from '@elastic/eui'; import deepEqual from 'fast-deep-equal'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch } from 'react-redux'; -import styled from 'styled-components'; import * as i18n from './translations'; import { sourcererActions, sourcererModel, sourcererSelectors } from '../../store/sourcerer'; -import { getScopePatternListSelection } from '../../store/sourcerer/helpers'; import { useDeepEqualSelector } from '../../hooks/use_selector'; import { SourcererScopeName } from '../../store/sourcerer/model'; +import { usePickIndexPatterns } from './use_pick_index_patterns'; +import { + FormRow, + getDataViewSelectOptions, + getTooltipContent, + PopoverContent, + ResetButton, + StyledBadge, + StyledButton, + StyledFormRow, +} from './helpers'; -const PopoverContent = styled.div` - width: 600px; -`; - -const ResetButton = styled(EuiButtonEmpty)` - width: fit-content; -`; interface SourcererComponentProps { scope: sourcererModel.SourcererScopeName; } -const getPatternListWithoutSignals = ( - patternList: string[], - signalIndexName: string | null -): string[] => patternList.filter((p) => p !== signalIndexName); - export const Sourcerer = React.memo(({ scope: scopeId }) => { const dispatch = useDispatch(); + const isDetectionsSourcerer = scopeId === SourcererScopeName.detections; + const isTimelineSourcerer = scopeId === SourcererScopeName.timeline; + + const [isOnlyDetectionAlertsChecked, setIsOnlyDetectionAlertsChecked] = useState(false); + + const isOnlyDetectionAlerts: boolean = + isDetectionsSourcerer || (isTimelineSourcerer && isOnlyDetectionAlertsChecked); + const sourcererScopeSelector = useMemo(() => sourcererSelectors.getSourcererScopeSelector(), []); const { defaultDataView, @@ -58,55 +62,39 @@ export const Sourcerer = React.memo(({ scope: scopeId } } = useDeepEqualSelector((state) => sourcererScopeSelector(state, scopeId)); const [isPopoverOpen, setPopoverIsOpen] = useState(false); - const [dataViewId, setDataViewId] = useState(selectedDataViewId ?? defaultDataView.id); - const { patternList, selectablePatterns } = useMemo(() => { - const theDataView = kibanaDataViews.find((dataView) => dataView.id === dataViewId); - return theDataView != null - ? scopeId === SourcererScopeName.default - ? { - patternList: getPatternListWithoutSignals( - theDataView.title - .split(',') - // remove duplicates patterns from selector - .filter((pattern, i, self) => self.indexOf(pattern) === i), - signalIndexName - ), - selectablePatterns: getPatternListWithoutSignals( - theDataView.patternList, - signalIndexName - ), - } - : { - patternList: theDataView.title - .split(',') - // remove duplicates patterns from selector - .filter((pattern, i, self) => self.indexOf(pattern) === i), - selectablePatterns: theDataView.patternList, - } - : { patternList: [], selectablePatterns: [] }; - }, [kibanaDataViews, scopeId, signalIndexName, dataViewId]); - - const selectableOptions = useMemo( - () => - patternList.map((indexName) => ({ - label: indexName, - value: indexName, - disabled: !selectablePatterns.includes(indexName), - })), - [selectablePatterns, patternList] - ); - - const [selectedOptions, setSelectedOptions] = useState>>( - selectedPatterns.map((indexName) => ({ - label: indexName, - value: indexName, - })) + const { + isModified, + onChangeCombo, + renderOption, + selectableOptions, + selectedOptions, + setIndexPatternsByDataView, + } = usePickIndexPatterns({ + dataViewId, + defaultDataViewId: defaultDataView.id, + isOnlyDetectionAlerts, + kibanaDataViews, + scopeId, + selectedPatterns, + signalIndexName, + }); + const onCheckboxChanged = useCallback( + (e) => { + setIsOnlyDetectionAlertsChecked(e.target.checked); + setDataViewId(defaultDataView.id); + setIndexPatternsByDataView(defaultDataView.id, e.target.checked); + }, + [defaultDataView.id, setIndexPatternsByDataView] ); const isSavingDisabled = useMemo(() => selectedOptions.length === 0, [selectedOptions]); + const [expandAdvancedOptions, setExpandAdvancedOptions] = useState(false); - const setPopoverIsOpenCb = useCallback(() => setPopoverIsOpen((prevState) => !prevState), []); + const setPopoverIsOpenCb = useCallback(() => { + setPopoverIsOpen((prevState) => !prevState); + setExpandAdvancedOptions(false); // we always want setExpandAdvancedOptions collapsed by default when popover opened + }, []); const onChangeDataView = useCallback( (newSelectedDataView: string, newSelectedPatterns: string[]) => { dispatch( @@ -120,90 +108,64 @@ export const Sourcerer = React.memo(({ scope: scopeId } [dispatch, scopeId] ); - const renderOption = useCallback( - ({ value }) => {value}, - [] - ); - - const onChangeCombo = useCallback((newSelectedOptions) => { - setSelectedOptions(newSelectedOptions); - }, []); - const onChangeSuper = useCallback( (newSelectedOption) => { setDataViewId(newSelectedOption); - setSelectedOptions( - getScopePatternListSelection( - kibanaDataViews.find((dataView) => dataView.id === newSelectedOption), - scopeId, - signalIndexName, - newSelectedOption === defaultDataView.id - ).map((indexSelected: string) => ({ - label: indexSelected, - value: indexSelected, - })) - ); + setIndexPatternsByDataView(newSelectedOption); }, - [defaultDataView.id, kibanaDataViews, scopeId, signalIndexName] + [setIndexPatternsByDataView] ); const resetDataSources = useCallback(() => { setDataViewId(defaultDataView.id); - setSelectedOptions( - getScopePatternListSelection(defaultDataView, scopeId, signalIndexName, true).map( - (indexSelected: string) => ({ - label: indexSelected, - value: indexSelected, - }) - ) - ); - }, [defaultDataView, scopeId, signalIndexName]); + setIndexPatternsByDataView(defaultDataView.id); + setIsOnlyDetectionAlertsChecked(false); + }, [defaultDataView.id, setIndexPatternsByDataView]); const handleSaveIndices = useCallback(() => { - onChangeDataView( - dataViewId, - selectedOptions.map((so) => so.label) - ); + const patterns = selectedOptions.map((so) => so.label); + onChangeDataView(dataViewId, patterns); setPopoverIsOpen(false); }, [onChangeDataView, dataViewId, selectedOptions]); const handleClosePopOver = useCallback(() => { setPopoverIsOpen(false); + setExpandAdvancedOptions(false); }, []); const trigger = useMemo( () => ( - - {i18n.SOURCERER} - + {i18n.DATA_VIEW} + {isModified === 'modified' && {i18n.MODIFIED_BADGE_TITLE}} + {isModified === 'alerts' && ( + + {i18n.ALERTS_BADGE_TITLE} + + )} + ), - [setPopoverIsOpenCb, loading] + [isTimelineSourcerer, loading, setPopoverIsOpenCb, isModified] ); const dataViewSelectOptions = useMemo( () => - kibanaDataViews.map(({ title, id }) => ({ - inputDisplay: - id === defaultDataView.id ? ( - - {i18n.SIEM_DATA_VIEW_LABEL} - - ) : ( - - {title} - - ), - value: id, - })), - [defaultDataView.id, kibanaDataViews] + getDataViewSelectOptions({ + dataViewId, + defaultDataView, + isModified: isModified === 'modified', + isOnlyDetectionAlerts, + kibanaDataViews, + }), + [dataViewId, defaultDataView, isModified, isOnlyDetectionAlerts, kibanaDataViews] ); useEffect(() => { @@ -213,18 +175,16 @@ export const Sourcerer = React.memo(({ scope: scopeId } : prevSelectedOption ); }, [selectedDataViewId]); - useEffect(() => { - setSelectedOptions( - selectedPatterns.map((indexName) => ({ - label: indexName, - value: indexName, - })) - ); - }, [selectedPatterns]); const tooltipContent = useMemo( - () => (isPopoverOpen ? null : selectedPatterns.join(', ')), - [selectedPatterns, isPopoverOpen] + () => + getTooltipContent({ + isOnlyDetectionAlerts, + isPopoverOpen, + selectedPatterns, + signalIndexName, + }), + [isPopoverOpen, isOnlyDetectionAlerts, signalIndexName, selectedPatterns] ); const buttonWithTooptip = useMemo(() => { @@ -237,67 +197,117 @@ export const Sourcerer = React.memo(({ scope: scopeId } ); }, [trigger, tooltipContent]); + const onExpandAdvancedOptionsClicked = useCallback(() => { + setExpandAdvancedOptions((prevState) => !prevState); + }, []); + return ( - - <>{i18n.SELECT_INDEX_PATTERNS} + + <>{i18n.SELECT_DATA_VIEW} + {isOnlyDetectionAlerts && ( + + )} - {i18n.INDEX_PATTERNS_SELECTION_LABEL} - - - - - - - - - {i18n.INDEX_PATTERNS_RESET} - - - - + {isTimelineSourcerer && ( + + + + )} + + + - {i18n.SAVE_INDEX_PATTERNS} - - - + onChange={onChangeSuper} + options={dataViewSelectOptions} + placeholder={i18n.PICK_INDEX_PATTERNS} + valueOfSelected={dataViewId} + /> + + + + + + {i18n.INDEX_PATTERNS_ADVANCED_OPTIONS_TITLE} + + {expandAdvancedOptions && } + + + + + {!isDetectionsSourcerer && ( + + + + + {i18n.INDEX_PATTERNS_RESET} + + + + + {i18n.SAVE_INDEX_PATTERNS} + + + + + )} + + ); diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/translations.ts b/x-pack/plugins/security_solution/public/common/components/sourcerer/translations.ts index 03fdc5d19171..fcf465ebfc9e 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/translations.ts @@ -7,29 +7,85 @@ import { i18n } from '@kbn/i18n'; -export const SOURCERER = i18n.translate('xpack.securitySolution.indexPatterns.dataSourcesLabel', { - defaultMessage: 'Data sources', +export const CALL_OUT_TITLE = i18n.translate('xpack.securitySolution.indexPatterns.callOutTitle', { + defaultMessage: 'Data view cannot be modified on this page', }); -export const SIEM_DATA_VIEW_LABEL = i18n.translate( - 'xpack.securitySolution.indexPatterns.kipLabel', +export const CALL_OUT_TIMELINE_TITLE = i18n.translate( + 'xpack.securitySolution.indexPatterns.callOutTimelineTitle', { - defaultMessage: 'Default Security Data View', + defaultMessage: 'Data view cannot be modified when show only detection alerts is selected', } ); -export const SELECT_INDEX_PATTERNS = i18n.translate('xpack.securitySolution.indexPatterns.help', { - defaultMessage: 'Data sources selection', +export const DATA_VIEW = i18n.translate('xpack.securitySolution.indexPatterns.dataViewLabel', { + defaultMessage: 'Data view', }); +export const MODIFIED_BADGE_TITLE = i18n.translate( + 'xpack.securitySolution.indexPatterns.modifiedBadgeTitle', + { + defaultMessage: 'Modified', + } +); + +export const ALERTS_BADGE_TITLE = i18n.translate( + 'xpack.securitySolution.indexPatterns.alertsBadgeTitle', + { + defaultMessage: 'Alerts', + } +); + +export const SECURITY_DEFAULT_DATA_VIEW_LABEL = i18n.translate( + 'xpack.securitySolution.indexPatterns.securityDefaultDataViewLabel', + { + defaultMessage: 'Security Default Data View', + } +); + +export const SIEM_SECURITY_DATA_VIEW_LABEL = i18n.translate( + 'xpack.securitySolution.indexPatterns.securityDataViewLabel', + { + defaultMessage: 'Security Data View', + } +); + +export const SELECT_DATA_VIEW = i18n.translate( + 'xpack.securitySolution.indexPatterns.selectDataView', + { + defaultMessage: 'Data view selection', + } +); export const SAVE_INDEX_PATTERNS = i18n.translate('xpack.securitySolution.indexPatterns.save', { defaultMessage: 'Save', }); -export const INDEX_PATTERNS_SELECTION_LABEL = i18n.translate( - 'xpack.securitySolution.indexPatterns.selectionLabel', +export const INDEX_PATTERNS_CHOOSE_DATA_VIEW_LABEL = i18n.translate( + 'xpack.securitySolution.indexPatterns.chooseDataViewLabel', { - defaultMessage: 'Choose the source of the data on this page', + defaultMessage: 'Choose data view', + } +); + +export const INDEX_PATTERNS_ADVANCED_OPTIONS_TITLE = i18n.translate( + 'xpack.securitySolution.indexPatterns.advancedOptionsTitle', + { + defaultMessage: 'Advanced options', + } +); + +export const INDEX_PATTERNS_LABEL = i18n.translate( + 'xpack.securitySolution.indexPatterns.indexPatternsLabel', + { + defaultMessage: 'Index patterns', + } +); + +export const INDEX_PATTERNS_DESCRIPTIONS = i18n.translate( + 'xpack.securitySolution.indexPatterns.descriptionsLabel', + { + defaultMessage: + 'These are the index patterns currently selected. Filtering out index patterns from your data view can help improve overall performance.', } ); @@ -54,3 +110,10 @@ export const PICK_INDEX_PATTERNS = i18n.translate( defaultMessage: 'Pick index patterns', } ); + +export const ALERTS_CHECKBOX_LABEL = i18n.translate( + 'xpack.securitySolution.indexPatterns.onlyDetectionAlertsLabel', + { + defaultMessage: 'Show only detection alerts', + } +); 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 new file mode 100644 index 000000000000..2ed231949939 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/use_pick_index_patterns.tsx @@ -0,0 +1,167 @@ +/* + * 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, { useCallback, useEffect, useMemo, useState } from 'react'; +import { EuiComboBoxOptionOption } from '@elastic/eui'; +import { getScopePatternListSelection } from '../../store/sourcerer/helpers'; +import { sourcererModel } from '../../store/sourcerer'; +import { getPatternListWithoutSignals } from './helpers'; +import { SourcererScopeName } from '../../store/sourcerer/model'; + +interface UsePickIndexPatternsProps { + dataViewId: string; + defaultDataViewId: string; + isOnlyDetectionAlerts: boolean; + kibanaDataViews: sourcererModel.SourcererModel['kibanaDataViews']; + scopeId: sourcererModel.SourcererScopeName; + selectedPatterns: string[]; + signalIndexName: string | null; +} + +export type ModifiedTypes = 'modified' | 'alerts' | ''; + +interface UsePickIndexPatterns { + isModified: ModifiedTypes; + onChangeCombo: (newSelectedDataViewId: Array>) => void; + renderOption: ({ value }: EuiComboBoxOptionOption) => React.ReactElement; + selectableOptions: Array>; + selectedOptions: Array>; + setIndexPatternsByDataView: (newSelectedDataViewId: string, isAlerts?: boolean) => void; +} + +const patternListToOptions = (patternList: string[], selectablePatterns?: string[]) => + patternList.sort().map((s) => ({ + label: s, + value: s, + ...(selectablePatterns != null ? { disabled: !selectablePatterns.includes(s) } : {}), + })); + +export const usePickIndexPatterns = ({ + dataViewId, + defaultDataViewId, + isOnlyDetectionAlerts, + kibanaDataViews, + scopeId, + selectedPatterns, + signalIndexName, +}: UsePickIndexPatternsProps): UsePickIndexPatterns => { + const alertsOptions = useMemo( + () => (signalIndexName ? patternListToOptions([signalIndexName]) : []), + [signalIndexName] + ); + + const { patternList, selectablePatterns } = useMemo(() => { + if (isOnlyDetectionAlerts && signalIndexName) { + return { + patternList: [signalIndexName], + selectablePatterns: [signalIndexName], + }; + } + const theDataView = kibanaDataViews.find((dataView) => dataView.id === dataViewId); + return theDataView != null + ? scopeId === sourcererModel.SourcererScopeName.default + ? { + patternList: getPatternListWithoutSignals( + theDataView.title + .split(',') + // remove duplicates patterns from selector + .filter((pattern, i, self) => self.indexOf(pattern) === i), + signalIndexName + ), + selectablePatterns: getPatternListWithoutSignals( + theDataView.patternList, + signalIndexName + ), + } + : { + patternList: theDataView.title + .split(',') + // remove duplicates patterns from selector + .filter((pattern, i, self) => self.indexOf(pattern) === i), + selectablePatterns: theDataView.patternList, + } + : { patternList: [], selectablePatterns: [] }; + }, [dataViewId, isOnlyDetectionAlerts, kibanaDataViews, scopeId, signalIndexName]); + + const selectableOptions = useMemo( + () => patternListToOptions(patternList, selectablePatterns), + [patternList, selectablePatterns] + ); + const [selectedOptions, setSelectedOptions] = useState>>( + isOnlyDetectionAlerts ? alertsOptions : patternListToOptions(selectedPatterns) + ); + + const getDefaultSelectedOptionsByDataView = useCallback( + (id: string, isAlerts: boolean = false): Array> => + scopeId === SourcererScopeName.detections || isAlerts + ? alertsOptions + : patternListToOptions( + getScopePatternListSelection( + kibanaDataViews.find((dataView) => dataView.id === id), + scopeId, + signalIndexName, + id === defaultDataViewId + ) + ), + [alertsOptions, kibanaDataViews, scopeId, signalIndexName, defaultDataViewId] + ); + + const defaultSelectedPatternsAsOptions = useMemo( + () => getDefaultSelectedOptionsByDataView(dataViewId), + [dataViewId, getDefaultSelectedOptionsByDataView] + ); + + const [isModified, setIsModified] = useState<'modified' | 'alerts' | ''>(''); + const onSetIsModified = useCallback( + (patterns?: string[]) => { + if (isOnlyDetectionAlerts) { + return setIsModified('alerts'); + } + const modifiedPatterns = patterns != null ? patterns : selectedPatterns; + const isPatternsModified = + defaultSelectedPatternsAsOptions.length !== modifiedPatterns.length || + !defaultSelectedPatternsAsOptions.every((option) => + modifiedPatterns.find((pattern) => option.value === pattern) + ); + return setIsModified(isPatternsModified ? 'modified' : ''); + }, + [defaultSelectedPatternsAsOptions, isOnlyDetectionAlerts, selectedPatterns] + ); + + // when scope updates, check modified to set/remove alerts label + useEffect(() => { + setSelectedOptions( + scopeId === SourcererScopeName.detections + ? alertsOptions + : patternListToOptions(selectedPatterns) + ); + onSetIsModified(selectedPatterns); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scopeId, selectedPatterns]); + + const onChangeCombo = useCallback((newSelectedOptions) => { + setSelectedOptions(newSelectedOptions); + }, []); + + const renderOption = useCallback( + ({ value }) => {value}, + [] + ); + + const setIndexPatternsByDataView = (newSelectedDataViewId: string, isAlerts?: boolean) => { + setSelectedOptions(getDefaultSelectedOptionsByDataView(newSelectedDataViewId, isAlerts)); + }; + + return { + isModified, + onChangeCombo, + renderOption, + selectableOptions, + selectedOptions, + setIndexPatternsByDataView, + }; +}; 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 4ca8bf037261..2edfc1336269 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 @@ -13,7 +13,15 @@ import { sourcererActions, sourcererSelectors } from '../../store/sourcerer'; import { SelectedDataView, SourcererScopeName } from '../../store/sourcerer/model'; import { useUserInfo } from '../../../detections/components/user_info'; import { timelineSelectors } from '../../../timelines/store/timeline'; -import { ALERTS_PATH, CASES_PATH, RULES_PATH, UEBA_PATH } from '../../../../common/constants'; +import { + ALERTS_PATH, + CASES_PATH, + HOSTS_PATH, + NETWORK_PATH, + OVERVIEW_PATH, + RULES_PATH, + UEBA_PATH, +} from '../../../../common/constants'; import { TimelineId } from '../../../../common'; import { useDeepEqualSelector } from '../../hooks/use_selector'; import { getScopePatternListSelection } from '../../store/sourcerer/helpers'; @@ -300,3 +308,24 @@ export const getScopeFromPath = ( }) == null ? SourcererScopeName.default : SourcererScopeName.detections; + +export const sourcererPaths = [ + ALERTS_PATH, + `${RULES_PATH}/id/:id`, + HOSTS_PATH, + NETWORK_PATH, + OVERVIEW_PATH, + UEBA_PATH, +]; + +export const showSourcererByPath = (pathname: string): boolean => + matchPath(pathname, { + path: sourcererPaths, + strict: false, + }) != null; + +export const isAlertsOrRulesDetailsPage = (pathname: string): boolean => + matchPath(pathname, { + path: [ALERTS_PATH, `${RULES_PATH}/id/:id`], + strict: false, + }) != null; diff --git a/x-pack/plugins/security_solution/public/common/store/sourcerer/helpers.ts b/x-pack/plugins/security_solution/public/common/store/sourcerer/helpers.ts index 1b4efa72127f..c99ed720c7f0 100644 --- a/x-pack/plugins/security_solution/public/common/store/sourcerer/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/store/sourcerer/helpers.ts @@ -37,15 +37,7 @@ export const getScopePatternListSelection = ( // set to signalIndexName whether or not it exists yet in the patternList return (signalIndexName != null ? [signalIndexName] : []).sort(); case SourcererScopeName.timeline: - return ( - signalIndexName != null - ? [ - // remove signalIndexName in case its already in there and add it whether or not it exists yet in the patternList - ...patternList.filter((index) => index !== signalIndexName), - signalIndexName, - ] - : patternList - ).sort(); + return patternList.sort(); } }; @@ -96,16 +88,14 @@ export const validateSelectedPatterns = ( selectedDataViewId: dataView?.id ?? null, selectedPatterns, ...(isEmpty(selectedPatterns) - ? id === SourcererScopeName.timeline - ? defaultDataViewByEventType({ state, eventType }) - : { - selectedPatterns: getScopePatternListSelection( - dataView ?? state.defaultDataView, - id, - state.signalIndexName, - (dataView ?? state.defaultDataView).id === state.defaultDataView.id - ), - } + ? { + selectedPatterns: getScopePatternListSelection( + dataView ?? state.defaultDataView, + id, + state.signalIndexName, + (dataView ?? state.defaultDataView).id === state.defaultDataView.id + ), + } : {}), loading: false, }, diff --git a/x-pack/plugins/security_solution/public/detections/components/detection_engine_header_page/index.tsx b/x-pack/plugins/security_solution/public/detections/components/detection_engine_header_page/index.tsx index 92911ab28537..44f27b690fbc 100644 --- a/x-pack/plugins/security_solution/public/detections/components/detection_engine_header_page/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/detection_engine_header_page/index.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { HeaderPage, HeaderPageProps } from '../../../common/components/header_page'; const DetectionEngineHeaderPageComponent: React.FC = (props) => ( - + ); export const DetectionEngineHeaderPage = React.memo(DetectionEngineHeaderPageComponent); diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx index 3a98f062db65..67ee6c55ac06 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx @@ -28,8 +28,6 @@ import { EndpointNotice } from '../components/endpoint_notice'; import { useMessagesStorage } from '../../common/containers/local_storage/use_messages_storage'; import { ENDPOINT_METADATA_INDEX } from '../../../common/constants'; import { useSourcererDataView } from '../../common/containers/sourcerer'; -import { Sourcerer } from '../../common/components/sourcerer'; -import { SourcererScopeName } from '../../common/store/sourcerer/model'; import { useDeepEqualSelector } from '../../common/hooks/use_selector'; import { ThreatIntelLinkPanel } from '../components/overview_cti_links'; import { useIsThreatIntelModuleEnabled } from '../containers/overview_cti_links/use_is_threat_intel_module_enabled'; @@ -96,7 +94,6 @@ const OverviewComponent = () => { )} - 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 6cb0e6f2e798..4bd963b21a7f 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 @@ -42,7 +42,6 @@ import { requiredFieldsForActions } from '../../../../detections/components/aler import { ExitFullScreen } from '../../../../common/components/exit_full_screen'; import { SuperDatePicker } from '../../../../common/components/super_date_picker'; import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context'; -import { PickEventType } from '../search_or_filter/pick_events'; import { inputsModel, inputsSelectors, State } from '../../../../common/store'; import { sourcererActions } from '../../../../common/store/sourcerer'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; @@ -57,6 +56,7 @@ import { DetailsPanel } from '../../side_panel'; import { EqlQueryBarTimeline } from '../query_bar/eql'; import { defaultControlColumn } from '../body/control_columns'; import { Sort } from '../body/sort'; +import { Sourcerer } from '../../../../common/components/sourcerer'; const TimelineHeaderContainer = styled.div` margin-top: 6px; @@ -283,10 +283,7 @@ export const EqlTabContentComponent: React.FC = ({ - + 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 5f6f2796d4ba..6d53e7194306 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 @@ -46,7 +46,6 @@ import { import { requiredFieldsForActions } from '../../../../detections/components/alerts_table/default_config'; import { SuperDatePicker } from '../../../../common/components/super_date_picker'; import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context'; -import { PickEventType } from '../search_or_filter/pick_events'; import { inputsModel, inputsSelectors, State } from '../../../../common/store'; import { sourcererActions } from '../../../../common/store/sourcerer'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; @@ -61,6 +60,7 @@ import { DetailsPanel } from '../../side_panel'; import { ExitFullScreen } from '../../../../common/components/exit_full_screen'; import { defaultControlColumn } from '../body/control_columns'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { Sourcerer } from '../../../../common/components/sourcerer'; const TimelineHeaderContainer = styled.div` margin-top: 6px; @@ -358,10 +358,7 @@ export const QueryTabContentComponent: React.FC = ({ - + diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/pick_events.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/pick_events.test.tsx deleted file mode 100644 index 47ea0f781f7c..000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/pick_events.test.tsx +++ /dev/null @@ -1,220 +0,0 @@ -/* - * 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 { fireEvent, render, within } from '@testing-library/react'; -import { EuiToolTip } from '@elastic/eui'; - -import React from 'react'; -import { PickEventType } from './pick_events'; -import { - createSecuritySolutionStorageMock, - kibanaObservable, - mockGlobalState, - mockSourcererState, - SUB_PLUGINS_REDUCER, - TestProviders, -} from '../../../../common/mock'; -import { TimelineEventsType } from '../../../../../common'; -import { createStore } from '../../../../common/store'; -import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; - -jest.mock('@elastic/eui', () => { - const actual = jest.requireActual('@elastic/eui'); - return { - ...actual, - EuiToolTip: jest.fn(), - }; -}); - -describe('Pick Events/Timeline Sourcerer', () => { - const defaultProps = { - eventType: 'all' as TimelineEventsType, - onChangeEventTypeAndIndexesName: jest.fn(), - }; - const initialPatterns = [ - ...mockSourcererState.defaultDataView.patternList.filter( - (p) => p !== mockSourcererState.signalIndexName - ), - mockSourcererState.signalIndexName, - ]; - const { storage } = createSecuritySolutionStorageMock(); - - // const state = { - // ...mockGlobalState, - // sourcerer: { - // ...mockGlobalState.sourcerer, - // kibanaIndexPatterns: [ - // { id: '1234', title: 'auditbeat-*' }, - // { id: '9100', title: 'filebeat-*' }, - // { id: '9100', title: 'auditbeat-*,filebeat-*' }, - // { id: '5678', title: 'auditbeat-*,.siem-signals-default' }, - // ], - // configIndexPatterns: - // mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline].selectedPatterns, - // signalIndexName: mockGlobalState.sourcerer.signalIndexName, - // sourcererScopes: { - // ...mockGlobalState.sourcerer.sourcererScopes, - // [SourcererScopeName.timeline]: { - // ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline], - // loading: false, - // selectedPatterns: ['filebeat-*'], - // }, - // }, - // }, - // }; - // const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - const state = { - ...mockGlobalState, - sourcerer: { - ...mockGlobalState.sourcerer, - sourcererScopes: { - ...mockGlobalState.sourcerer.sourcererScopes, - [SourcererScopeName.timeline]: { - ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline], - loading: false, - selectedDataViewId: mockGlobalState.sourcerer.defaultDataView.id, - selectedPatterns: ['filebeat-*'], - }, - }, - }, - }; - const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - const mockTooltip = ({ - tooltipContent, - children, - }: { - tooltipContent: string; - children: React.ReactElement; - }) => ( -
- {tooltipContent} - {children} -
- ); - - beforeAll(() => { - (EuiToolTip as unknown as jest.Mock).mockImplementation(mockTooltip); - }); - beforeEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); - }); - it('renders', () => { - const wrapper = render( - - - - ); - fireEvent.click(wrapper.getByTestId('sourcerer-timeline-trigger')); - expect(wrapper.getByTestId('timeline-sourcerer').textContent).toEqual( - initialPatterns.sort().join('') - ); - fireEvent.click(wrapper.getByTestId(`sourcerer-accordion`)); - fireEvent.click(wrapper.getByTestId('comboBoxToggleListButton')); - const optionNodes = wrapper.getAllByTestId('sourcerer-option'); - expect(optionNodes.length).toBe(1); - }); - it('Removes duplicate options from options list', () => { - const store2 = createStore( - { - ...mockGlobalState, - sourcerer: { - ...mockGlobalState.sourcerer, - defaultDataView: { - ...mockGlobalState.sourcerer.defaultDataView, - id: '1234', - title: 'filebeat-*,auditbeat-*,auditbeat-*,auditbeat-*,auditbeat-*', - patternList: ['filebeat-*', 'auditbeat-*'], - }, - kibanaDataViews: [ - { - ...mockGlobalState.sourcerer.defaultDataView, - id: '1234', - title: 'filebeat-*,auditbeat-*,auditbeat-*,auditbeat-*,auditbeat-*', - patternList: ['filebeat-*', 'auditbeat-*'], - }, - ], - sourcererScopes: { - ...mockGlobalState.sourcerer.sourcererScopes, - [SourcererScopeName.timeline]: { - ...mockGlobalState.sourcerer.sourcererScopes[SourcererScopeName.timeline], - loading: false, - selectedDataViewId: '1234', - selectedPatterns: ['filebeat-*'], - }, - }, - }, - }, - SUB_PLUGINS_REDUCER, - kibanaObservable, - storage - ); - const wrapper = render( - - - - ); - fireEvent.click(wrapper.getByTestId(`sourcerer-timeline-trigger`)); - fireEvent.click(wrapper.getByTestId(`sourcerer-accordion`)); - fireEvent.click(wrapper.getByTestId(`comboBoxToggleListButton`)); - expect( - wrapper.getByTestId('comboBoxOptionsList timeline-sourcerer-optionsList').textContent - ).toEqual('auditbeat-*'); - }); - - it('renders tooltip', () => { - render( - - - - ); - - expect((EuiToolTip as unknown as jest.Mock).mock.calls[0][0].content).toEqual( - initialPatterns - .filter((p) => p != null) - .sort() - .join(', ') - ); - }); - - it('renders popover button inside tooltip', () => { - const wrapper = render( - - - - ); - const tooltip = wrapper.getByTestId('timeline-sourcerer-tooltip'); - expect(within(tooltip).getByTestId('sourcerer-timeline-trigger')).toBeTruthy(); - }); - - it('correctly filters options', () => { - const wrapper = render( - - - - ); - fireEvent.click(wrapper.getByTestId('sourcerer-timeline-trigger')); - fireEvent.click(wrapper.getByTestId('comboBoxToggleListButton')); - fireEvent.click(wrapper.getByTestId('sourcerer-accordion')); - const optionNodes = wrapper.getAllByTestId('sourcerer-option'); - expect(optionNodes.length).toBe(9); - }); - it('reset button works', () => { - const wrapper = render( - - - - ); - fireEvent.click(wrapper.getByTestId('sourcerer-timeline-trigger')); - expect(wrapper.getByTestId('timeline-sourcerer').textContent).toEqual('filebeat-*'); - - fireEvent.click(wrapper.getByTestId('sourcerer-reset')); - expect(wrapper.getByTestId('timeline-sourcerer').textContent).toEqual( - initialPatterns.sort().join('') - ); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/pick_events.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/pick_events.tsx deleted file mode 100644 index 791993d67135..000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/pick_events.tsx +++ /dev/null @@ -1,465 +0,0 @@ -/* - * 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 { - EuiAccordion, - EuiButton, - EuiButtonEmpty, - EuiRadioGroup, - EuiComboBox, - EuiComboBoxOptionOption, - EuiHealth, - EuiIcon, - EuiFlexGroup, - EuiFlexItem, - EuiPopover, - EuiPopoverTitle, - EuiSpacer, - EuiText, - EuiToolTip, - EuiSuperSelect, -} from '@elastic/eui'; -import deepEqual from 'fast-deep-equal'; -import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; -import styled from 'styled-components'; - -import { sourcererSelectors } from '../../../../common/store'; -import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; -import { TimelineEventsType } from '../../../../../common'; -import * as i18n from './translations'; -import { getScopePatternListSelection } from '../../../../common/store/sourcerer/helpers'; -import { SIEM_DATA_VIEW_LABEL } from '../../../../common/components/sourcerer/translations'; -import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; - -const PopoverContent = styled.div` - width: 600px; -`; - -const ResetButton = styled(EuiButtonEmpty)` - width: fit-content; -`; - -const MyEuiButton = styled(EuiButton)` - .euiHealth { - vertical-align: middle; - } -`; - -const AllEuiHealth = styled(EuiHealth)` - margin-left: -2px; - svg { - stroke: #fff; - stroke-width: 1px; - stroke-linejoin: round; - width: 19px; - height: 19px; - margin-top: 1px; - z-index: 1; - } -`; - -const WarningEuiHealth = styled(EuiHealth)` - margin-left: -17px; - svg { - z-index: 0; - } -`; - -const AdvancedSettings = styled(EuiText)` - color: ${({ theme }) => theme.eui.euiColorPrimary}; -`; - -const ConfigHelper = styled(EuiText)` - margin-left: 4px; -`; - -const Filter = styled(EuiRadioGroup)` - margin-left: 4px; -`; - -const PickEventContainer = styled.div` - .euiSuperSelect { - width: 170px; - max-width: 170px; - button.euiSuperSelectControl { - padding-top: 3px; - } - } -`; - -const getEventTypeOptions = (isCustomDisabled: boolean = true, isDefaultPattern: boolean) => [ - { - id: 'all', - label: ( - - {i18n.ALL_EVENT} - - ), - }, - { - id: 'raw', - label: {i18n.RAW_EVENT}, - disabled: !isDefaultPattern, - }, - { - id: 'alert', - label: {i18n.DETECTION_ALERTS_EVENT}, - disabled: !isDefaultPattern, - }, - { - id: 'custom', - label: <>{i18n.CUSTOM_INDEX_PATTERNS}, - disabled: isCustomDisabled, - }, -]; - -interface PickEventTypeProps { - eventType: TimelineEventsType; - onChangeEventTypeAndIndexesName: ( - value: TimelineEventsType, - indexNames: string[], - dataViewId: string - ) => void; -} - -// AKA TimelineSourcerer -const PickEventTypeComponents: React.FC = ({ - eventType = 'all', - onChangeEventTypeAndIndexesName, -}) => { - const [isPopoverOpen, setPopover] = useState(false); - const [showAdvanceSettings, setAdvanceSettings] = useState(eventType === 'custom'); - const [filterEventType, setFilterEventType] = useState(eventType); - const sourcererScopeSelector = useMemo(() => sourcererSelectors.getSourcererScopeSelector(), []); - const { - defaultDataView, - kibanaDataViews, - signalIndexName, - sourcererScope: { loading, selectedPatterns, selectedDataViewId }, - }: sourcererSelectors.SourcererScopeSelector = useDeepEqualSelector((state) => - sourcererScopeSelector(state, SourcererScopeName.timeline) - ); - - const [dataViewId, setDataViewId] = useState(selectedDataViewId ?? ''); - const { patternList, selectablePatterns } = useMemo(() => { - const theDataView = kibanaDataViews.find((dataView) => dataView.id === dataViewId); - return theDataView != null - ? { - patternList: theDataView.title - .split(',') - // remove duplicates patterns from selector - .filter((pattern, i, self) => self.indexOf(pattern) === i), - selectablePatterns: theDataView.patternList, - } - : { patternList: [], selectablePatterns: [] }; - }, [kibanaDataViews, dataViewId]); - const [selectedOptions, setSelectedOptions] = useState>>( - selectedPatterns.map((indexName) => ({ - label: indexName, - value: indexName, - })) - ); - const isSavingDisabled = useMemo(() => selectedOptions.length === 0, [selectedOptions]); - const selectableOptions = useMemo( - () => - patternList.map((indexName) => ({ - label: indexName, - value: indexName, - 'data-test-subj': 'sourcerer-option', - disabled: !selectablePatterns.includes(indexName), - })), - [selectablePatterns, patternList] - ); - - const onChangeFilter = useCallback( - (filter) => { - setFilterEventType(filter); - if (filter === 'all' || filter === 'kibana') { - setSelectedOptions( - selectablePatterns.map((indexSelected) => ({ - label: indexSelected, - value: indexSelected, - })) - ); - } else if (filter === 'raw') { - setSelectedOptions( - (signalIndexName == null - ? selectablePatterns - : selectablePatterns.filter((index) => index !== signalIndexName) - ).map((indexSelected) => ({ - label: indexSelected, - value: indexSelected, - })) - ); - } else if (filter === 'alert') { - setSelectedOptions([ - { - label: signalIndexName ?? '', - value: signalIndexName ?? '', - }, - ]); - } - }, - [selectablePatterns, signalIndexName] - ); - - const onChangeCombo = useCallback( - (newSelectedOptions: Array>) => { - const localSelectedPatterns = newSelectedOptions - .map((nso) => nso.label) - .sort() - .join(); - if (localSelectedPatterns === selectablePatterns.sort().join()) { - setFilterEventType('all'); - } else if ( - dataViewId === defaultDataView.id && - localSelectedPatterns === - selectablePatterns - .filter((index) => index !== signalIndexName) - .sort() - .join() - ) { - setFilterEventType('raw'); - } else if (dataViewId === defaultDataView.id && localSelectedPatterns === signalIndexName) { - setFilterEventType('alert'); - } else { - setFilterEventType('custom'); - } - - setSelectedOptions(newSelectedOptions); - }, - [defaultDataView.id, dataViewId, selectablePatterns, signalIndexName] - ); - - const onChangeSuper = useCallback( - (newSelectedOption) => { - setFilterEventType('all'); - setDataViewId(newSelectedOption); - setSelectedOptions( - getScopePatternListSelection( - kibanaDataViews.find((dataView) => dataView.id === newSelectedOption), - SourcererScopeName.timeline, - signalIndexName, - newSelectedOption === defaultDataView.id - ).map((indexSelected: string) => ({ - label: indexSelected, - value: indexSelected, - })) - ); - }, - [defaultDataView.id, kibanaDataViews, signalIndexName] - ); - - const togglePopover = useCallback( - () => setPopover((prevIsPopoverOpen) => !prevIsPopoverOpen), - [] - ); - - const closePopover = useCallback(() => setPopover(false), []); - - const handleSaveIndices = useCallback(() => { - onChangeEventTypeAndIndexesName( - filterEventType, - selectedOptions.map((so) => so.label), - dataViewId - ); - setPopover(false); - }, [dataViewId, filterEventType, onChangeEventTypeAndIndexesName, selectedOptions]); - - const resetDataSources = useCallback(() => { - setDataViewId(defaultDataView.id); - setSelectedOptions( - getScopePatternListSelection( - defaultDataView, - SourcererScopeName.timeline, - signalIndexName, - true - ).map((indexSelected: string) => ({ - label: indexSelected, - value: indexSelected, - })) - ); - setFilterEventType(eventType); - }, [defaultDataView, eventType, signalIndexName]); - - const dataViewSelectOptions = useMemo( - () => - kibanaDataViews.map(({ title, id }) => ({ - inputDisplay: - id === defaultDataView.id ? ( - - {SIEM_DATA_VIEW_LABEL} - - ) : ( - - {title} - - ), - value: id, - })), - [defaultDataView.id, kibanaDataViews] - ); - - const filterOptions = useMemo( - () => getEventTypeOptions(filterEventType !== 'custom', dataViewId === defaultDataView.id), - [defaultDataView.id, filterEventType, dataViewId] - ); - - const button = useMemo(() => { - const options = getEventTypeOptions(true, dataViewId === defaultDataView.id); - return ( - - {options.find((opt) => opt.id === eventType)?.label} - - ); - }, [defaultDataView.id, eventType, dataViewId, loading, togglePopover]); - - const tooltipContent = useMemo( - () => (isPopoverOpen ? null : selectedPatterns.sort().join(', ')), - [isPopoverOpen, selectedPatterns] - ); - - const buttonWithTooptip = useMemo(() => { - return tooltipContent ? ( - - {button} - - ) : ( - button - ); - }, [button, tooltipContent]); - - const ButtonContent = useMemo( - () => ( - - {showAdvanceSettings - ? i18n.HIDE_INDEX_PATTERNS_ADVANCED_SETTINGS - : i18n.SHOW_INDEX_PATTERNS_ADVANCED_SETTINGS} - - ), - [showAdvanceSettings] - ); - - useEffect(() => { - const newSelectedOptions = selectedPatterns.map((indexSelected) => ({ - label: indexSelected, - value: indexSelected, - })); - setSelectedOptions((prevSelectedOptions) => { - if (!deepEqual(newSelectedOptions, prevSelectedOptions)) { - return newSelectedOptions; - } - return prevSelectedOptions; - }); - }, [selectedPatterns]); - - useEffect(() => { - setFilterEventType((prevFilter) => (prevFilter !== eventType ? eventType : prevFilter)); - setAdvanceSettings(eventType === 'custom'); - }, [eventType]); - - return ( - - - - - <>{i18n.SELECT_INDEX_PATTERNS} - - - - - - <> - - - - - - - {!showAdvanceSettings && ( - <> - - - {i18n.CONFIGURE_INDEX_PATTERNS} - - - )} - - - - - {i18n.DATA_SOURCES_RESET} - - - - - {i18n.SAVE_INDEX_PATTERNS} - - - - - - - ); -}; - -export const PickEventType = memo(PickEventTypeComponents); diff --git a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx index 3a9b9b0d2693..e59af74d9a47 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx @@ -46,7 +46,7 @@ export const TimelinesPageComponent: React.FC = () => { {indicesExist ? ( <> - + {capabilitiesCanUserCRUD && ( @@ -93,6 +93,7 @@ export const TimelinesPageComponent: React.FC = () => { ) : ( + )} diff --git a/x-pack/plugins/security_solution/public/ueba/pages/details/index.tsx b/x-pack/plugins/security_solution/public/ueba/pages/details/index.tsx index 72122aba3c4a..51c06fffb7b6 100644 --- a/x-pack/plugins/security_solution/public/ueba/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/ueba/pages/details/index.tsx @@ -99,7 +99,6 @@ const UebaDetailsComponent: React.FC = ({ detailName, uebaDeta 高度な設定で構成できます。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b6b274416042..9a8338015841 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -22426,13 +22426,10 @@ "xpack.securitySolution.hostsTable.unit": "{totalCount, plural, other {个主机}}", "xpack.securitySolution.hostsTable.versionTitle": "版本", "xpack.securitySolution.hoverActions.showTopTooltip": "显示排名靠前的{fieldName}", - "xpack.securitySolution.indexPatterns.dataSourcesLabel": "数据源", "xpack.securitySolution.indexPatterns.disabled": "在此页面上建议使用已禁用的索引模式,但是首先需要在 Kibana 索引模式设置中配置这些模式", - "xpack.securitySolution.indexPatterns.help": "数据源的选择", "xpack.securitySolution.indexPatterns.pickIndexPatternsCombo": "选取索引模式", "xpack.securitySolution.indexPatterns.resetButton": "重置", "xpack.securitySolution.indexPatterns.save": "保存", - "xpack.securitySolution.indexPatterns.selectionLabel": "在此页面上选择数据源", "xpack.securitySolution.insert.timeline.insertTimelineButton": "插入时间线链接", "xpack.securitySolution.inspect.modal.closeTitle": "关闭", "xpack.securitySolution.inspect.modal.indexPatternDescription": "连接到 Elasticsearch 索引的索引模式。可以在“Kibana”>“高级设置”中配置这些索引。",