Sourcerer UI (#117601)

This commit is contained in:
Steph Milovic 2021-11-10 18:34:50 -07:00 committed by GitHub
parent 9f02de6ed2
commit 6d951fee69
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1040 additions and 1046 deletions

View File

@ -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 = ({
</EuiButtonEmpty>
&emsp;
<EuiBadgeGroup gutterSize="s">
{index.id && index.id === 'security-solution' && (
<EuiBadge key="security-solution">{securityDataView}</EuiBadge>
)}
{index.tags &&
index.tags.map(({ key: tagKey, name: tagName }) => (
<EuiBadge key={tagKey}>{tagName}</EuiBadge>

View File

@ -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(
<TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} />
</TestProviders>
);
expect(getByText('Add integrations')).toBeInTheDocument();
});
it.each(sourcererPaths)('shows sourcerer on %s page', (pathname) => {
(useLocation as jest.Mock).mockReturnValue({ pathname });
const { getByTestId } = render(
<TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} />
</TestProviders>
);
expect(getByTestId('sourcerer-trigger')).toBeInTheDocument();
});
it('shows sourcerer on rule details page', () => {
(useLocation as jest.Mock).mockReturnValue({ pathname: sourcererPaths[2] });
const { getByTestId } = render(
<TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} />
</TestProviders>
);
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(
<TestProviders store={mockStore}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} />
</TestProviders>
);
expect(queryByTestId('sourcerer-trigger')).not.toBeInTheDocument();
});
});

View File

@ -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(<OutPortal node={portalNode} />);
@ -65,11 +81,14 @@ export const GlobalHeader = React.memo(
<EuiHeaderLink
color="primary"
data-test-subj="add-data"
href={prepend(ADD_DATA_PATH)}
href={href}
iconType="indexOpen"
>
{BUTTON_ADD_DATA}
</EuiHeaderLink>
{showSourcerer && !showTimeline && (
<Sourcerer scope={sourcererScope} data-test-subj="sourcerer" />
)}
</EuiHeaderLinks>
</EuiHeaderSectionItem>
</EuiHeaderSection>

View File

@ -9,8 +9,6 @@ import React from 'react';
import { HeaderPage, HeaderPageProps } from '../../../common/components/header_page';
const CaseHeaderPageComponent: React.FC<HeaderPageProps> = (props) => (
<HeaderPage hideSourcerer={true} {...props} />
);
const CaseHeaderPageComponent: React.FC<HeaderPageProps> = (props) => <HeaderPage {...props} />;
export const CaseHeaderPage = React.memo(CaseHeaderPageComponent);

View File

@ -33,9 +33,6 @@ exports[`HeaderPage it renders 1`] = `
Test supplement
</p>
</EuiPageHeaderSection>
<Sourcerer
scope="default"
/>
</EuiPageHeader>
<EuiSpacer
size="l"

View File

@ -21,8 +21,6 @@ import { Title } from './title';
import { DraggableArguments, BadgeOptions, TitleProp } from './types';
import { useFormatUrl } from '../link_to';
import { SecurityPageName } from '../../../app/types';
import { Sourcerer } from '../sourcerer';
import { SourcererScopeName } from '../../store/sourcerer/model';
import { useKibana } from '../../lib/kibana';
interface HeaderProps {
border?: boolean;
@ -76,8 +74,6 @@ export interface HeaderPageProps extends HeaderProps {
badgeOptions?: BadgeOptions;
children?: React.ReactNode;
draggableArguments?: DraggableArguments;
hideSourcerer?: boolean;
sourcererScope?: SourcererScopeName;
subtitle?: SubtitleProps['items'];
subtitle2?: SubtitleProps['items'];
title: TitleProp;
@ -116,9 +112,7 @@ const HeaderPageComponent: React.FC<HeaderPageProps> = ({
border,
children,
draggableArguments,
hideSourcerer = false,
isLoading,
sourcererScope = SourcererScopeName.default,
subtitle,
subtitle2,
title,
@ -149,7 +143,6 @@ const HeaderPageComponent: React.FC<HeaderPageProps> = ({
{children}
</EuiPageHeaderSection>
)}
{!hideSourcerer && <Sourcerer scope={sourcererScope} />}
</EuiPageHeader>
{/* Manually add a 'padding-bottom' to header */}
<EuiSpacer size="l" />

View File

@ -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)<EuiFormRowProps & { $expandAdvancedOptions: boolean }>`
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<EuiSuperSelectOption<string>> =>
isOnlyDetectionAlerts
? [
{
inputDisplay: (
<span data-test-subj="security-alerts-option-super">
<EuiIcon type="logoSecurity" size="s" /> {i18n.SIEM_SECURITY_DATA_VIEW_LABEL}
<StyledBadge data-test-subj="security-alerts-option-badge">
{i18n.ALERTS_BADGE_TITLE}
</StyledBadge>
</span>
),
value: defaultDataView.id,
},
]
: kibanaDataViews.map(({ title, id }) => ({
inputDisplay:
id === defaultDataView.id ? (
<span data-test-subj="security-option-super">
<EuiIcon type="logoSecurity" size="s" /> {i18n.SECURITY_DEFAULT_DATA_VIEW_LABEL}
{isModified && id === dataViewId && (
<StyledBadge>{i18n.MODIFIED_BADGE_TITLE}</StyledBadge>
)}
</span>
) : (
<span data-test-subj="dataView-option-super">
<EuiIcon type="logoKibana" size="s" /> {title}
{isModified && id === dataViewId && (
<StyledBadge>{i18n.MODIFIED_BADGE_TITLE}</StyledBadge>
)}
</span>
),
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);

View File

@ -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<typeof createStore>;
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(
<TestProviders store={store}>
<Sourcerer {...defaultProps} />
</TestProviders>
);
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(
<TestProviders store={store}>
<Sourcerer {...testProps} />
</TestProviders>
);
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(
<TestProviders>
@ -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', () => {
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
</TestProviders>
);
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(
<TestProviders store={store}>
<Sourcerer {...defaultProps} />
</TestProviders>
);
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', () => {
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
</TestProviders>
);
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(
<TestProviders store={store}>
<Sourcerer {...testProps} />
</TestProviders>
);
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(
<TestProviders store={store}>
<Sourcerer {...testProps} />
</TestProviders>
);
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(
<TestProviders store={store}>
<Sourcerer {...defaultProps} />
</TestProviders>
);
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-*'],
})
);
});
});

View File

@ -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<SourcererComponentProps>(({ 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<SourcererComponentProps>(({ scope: scopeId }
} = useDeepEqualSelector((state) => sourcererScopeSelector(state, scopeId));
const [isPopoverOpen, setPopoverIsOpen] = useState(false);
const [dataViewId, setDataViewId] = useState<string>(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<Array<EuiComboBoxOptionOption<string>>>(
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<SourcererComponentProps>(({ scope: scopeId }
[dispatch, scopeId]
);
const renderOption = useCallback(
({ value }) => <span data-test-subj="sourcerer-combo-option">{value}</span>,
[]
);
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(
() => (
<EuiButtonEmpty
aria-label={i18n.SOURCERER}
data-test-subj="sourcerer-trigger"
<StyledButton
aria-label={i18n.DATA_VIEW}
data-test-subj={isTimelineSourcerer ? 'timeline-sourcerer-trigger' : 'sourcerer-trigger'}
flush="left"
iconSide="right"
iconType="arrowDown"
isLoading={loading}
onClick={setPopoverIsOpenCb}
title={i18n.SOURCERER}
title={i18n.DATA_VIEW}
>
{i18n.SOURCERER}
</EuiButtonEmpty>
{i18n.DATA_VIEW}
{isModified === 'modified' && <StyledBadge>{i18n.MODIFIED_BADGE_TITLE}</StyledBadge>}
{isModified === 'alerts' && (
<StyledBadge data-test-subj="sourcerer-alerts-badge">
{i18n.ALERTS_BADGE_TITLE}
</StyledBadge>
)}
</StyledButton>
),
[setPopoverIsOpenCb, loading]
[isTimelineSourcerer, loading, setPopoverIsOpenCb, isModified]
);
const dataViewSelectOptions = useMemo(
() =>
kibanaDataViews.map(({ title, id }) => ({
inputDisplay:
id === defaultDataView.id ? (
<span data-test-subj="security-option-super">
<EuiIcon type="logoSecurity" size="s" /> {i18n.SIEM_DATA_VIEW_LABEL}
</span>
) : (
<span data-test-subj="dataView-option-super">
<EuiIcon type="logoKibana" size="s" /> {title}
</span>
),
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<SourcererComponentProps>(({ 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<SourcererComponentProps>(({ scope: scopeId }
);
}, [trigger, tooltipContent]);
const onExpandAdvancedOptionsClicked = useCallback(() => {
setExpandAdvancedOptions((prevState) => !prevState);
}, []);
return (
<EuiPopover
data-test-subj="sourcerer-popover"
data-test-subj={isTimelineSourcerer ? 'timeline-sourcerer-popover' : 'sourcerer-popover'}
button={buttonWithTooptip}
isOpen={isPopoverOpen}
closePopover={handleClosePopOver}
panelPaddingSize="s"
display="block"
repositionOnScroll
ownFocus
>
<PopoverContent>
<EuiPopoverTitle>
<>{i18n.SELECT_INDEX_PATTERNS}</>
<EuiPopoverTitle data-test-subj="sourcerer-title">
<>{i18n.SELECT_DATA_VIEW}</>
</EuiPopoverTitle>
{isOnlyDetectionAlerts && (
<EuiCallOut
data-test-subj="sourcerer-callout"
size="s"
iconType="iInCircle"
title={isTimelineSourcerer ? i18n.CALL_OUT_TIMELINE_TITLE : i18n.CALL_OUT_TITLE}
/>
)}
<EuiSpacer size="s" />
<EuiText color="default">{i18n.INDEX_PATTERNS_SELECTION_LABEL}</EuiText>
<EuiSpacer size="xs" />
<EuiSuperSelect
data-test-subj="sourcerer-select"
placeholder={i18n.PICK_INDEX_PATTERNS}
fullWidth
options={dataViewSelectOptions}
valueOfSelected={dataViewId}
onChange={onChangeSuper}
/>
<EuiSpacer size="xs" />
<EuiComboBox
data-test-subj="sourcerer-combo-box"
fullWidth
onChange={onChangeCombo}
options={selectableOptions}
placeholder={i18n.PICK_INDEX_PATTERNS}
renderOption={renderOption}
selectedOptions={selectedOptions}
/>
<EuiSpacer size="s" />
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem>
<ResetButton
aria-label={i18n.INDEX_PATTERNS_RESET}
data-test-subj="sourcerer-reset"
flush="left"
onClick={resetDataSources}
title={i18n.INDEX_PATTERNS_RESET}
>
{i18n.INDEX_PATTERNS_RESET}
</ResetButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={handleSaveIndices}
disabled={isSavingDisabled}
data-test-subj="sourcerer-save"
fill
<EuiForm component="form">
{isTimelineSourcerer && (
<StyledFormRow>
<EuiCheckbox
id="sourcerer-alert-only-checkbox"
data-test-subj="sourcerer-alert-only-checkbox"
label={i18n.ALERTS_CHECKBOX_LABEL}
checked={isOnlyDetectionAlertsChecked}
onChange={onCheckboxChanged}
/>
</StyledFormRow>
)}
<StyledFormRow label={i18n.INDEX_PATTERNS_CHOOSE_DATA_VIEW_LABEL}>
<EuiSuperSelect
data-test-subj="sourcerer-select"
disabled={isOnlyDetectionAlerts}
fullWidth
size="s"
>
{i18n.SAVE_INDEX_PATTERNS}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
onChange={onChangeSuper}
options={dataViewSelectOptions}
placeholder={i18n.PICK_INDEX_PATTERNS}
valueOfSelected={dataViewId}
/>
</StyledFormRow>
<EuiSpacer size="m" />
<StyledButton
color="text"
onClick={onExpandAdvancedOptionsClicked}
iconType={expandAdvancedOptions ? 'arrowDown' : 'arrowRight'}
data-test-subj="sourcerer-advanced-options-toggle"
>
{i18n.INDEX_PATTERNS_ADVANCED_OPTIONS_TITLE}
</StyledButton>
{expandAdvancedOptions && <EuiSpacer size="m" />}
<FormRow
label={i18n.INDEX_PATTERNS_LABEL}
$expandAdvancedOptions={expandAdvancedOptions}
helpText={isOnlyDetectionAlerts ? undefined : i18n.INDEX_PATTERNS_DESCRIPTIONS}
>
<EuiComboBox
data-test-subj="sourcerer-combo-box"
fullWidth
isDisabled={isOnlyDetectionAlerts}
onChange={onChangeCombo}
options={selectableOptions}
placeholder={i18n.PICK_INDEX_PATTERNS}
renderOption={renderOption}
selectedOptions={selectedOptions}
/>
</FormRow>
{!isDetectionsSourcerer && (
<StyledFormRow>
<EuiFlexGroup alignItems="center" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<ResetButton
aria-label={i18n.INDEX_PATTERNS_RESET}
data-test-subj="sourcerer-reset"
flush="left"
onClick={resetDataSources}
title={i18n.INDEX_PATTERNS_RESET}
>
{i18n.INDEX_PATTERNS_RESET}
</ResetButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={handleSaveIndices}
disabled={isSavingDisabled}
data-test-subj="sourcerer-save"
fill
fullWidth
size="s"
>
{i18n.SAVE_INDEX_PATTERNS}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</StyledFormRow>
)}
<EuiSpacer size="s" />
</EuiForm>
</PopoverContent>
</EuiPopover>
);

View File

@ -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',
}
);

View File

@ -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<EuiComboBoxOptionOption<string>>) => void;
renderOption: ({ value }: EuiComboBoxOptionOption<string>) => React.ReactElement;
selectableOptions: Array<EuiComboBoxOptionOption<string>>;
selectedOptions: Array<EuiComboBoxOptionOption<string>>;
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<Array<EuiComboBoxOptionOption<string>>>(
isOnlyDetectionAlerts ? alertsOptions : patternListToOptions(selectedPatterns)
);
const getDefaultSelectedOptionsByDataView = useCallback(
(id: string, isAlerts: boolean = false): Array<EuiComboBoxOptionOption<string>> =>
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 }) => <span data-test-subj="sourcerer-combo-option">{value}</span>,
[]
);
const setIndexPatternsByDataView = (newSelectedDataViewId: string, isAlerts?: boolean) => {
setSelectedOptions(getDefaultSelectedOptionsByDataView(newSelectedDataViewId, isAlerts));
};
return {
isModified,
onChangeCombo,
renderOption,
selectableOptions,
selectedOptions,
setIndexPatternsByDataView,
};
};

View File

@ -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;

View File

@ -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,
},

View File

@ -10,7 +10,7 @@ import React from 'react';
import { HeaderPage, HeaderPageProps } from '../../../common/components/header_page';
const DetectionEngineHeaderPageComponent: React.FC<HeaderPageProps> = (props) => (
<HeaderPage hideSourcerer={true} {...props} />
<HeaderPage {...props} />
);
export const DetectionEngineHeaderPage = React.memo(DetectionEngineHeaderPageComponent);

View File

@ -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 = () => {
<EuiSpacer size="l" />
</>
)}
<Sourcerer scope={SourcererScopeName.default} />
<EuiFlexGroup gutterSize="none" justifyContent="spaceBetween">
<SidebarFlexItem grow={false}>
<StatefulSidebar />

View File

@ -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<Props> = ({
<TimelineDatePickerLock />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<PickEventType
eventType={eventType}
onChangeEventTypeAndIndexesName={updateEventTypeAndIndexesName}
/>
<Sourcerer scope={SourcererScopeName.timeline} />
</EuiFlexItem>
</EuiFlexGroup>
<TimelineHeaderContainer data-test-subj="timelineHeader">

View File

@ -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<Props> = ({
<TimelineDatePickerLock />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<PickEventType
eventType={eventType}
onChangeEventTypeAndIndexesName={updateEventTypeAndIndexesName}
/>
<Sourcerer scope={SourcererScopeName.timeline} />
</EuiFlexItem>
</EuiFlexGroup>
<TimelineHeaderContainer data-test-subj="timelineHeader">

View File

@ -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;
}) => (
<div data-test-subj="timeline-sourcerer-tooltip">
<span>{tooltipContent}</span>
{children}
</div>
);
beforeAll(() => {
(EuiToolTip as unknown as jest.Mock).mockImplementation(mockTooltip);
});
beforeEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
it('renders', () => {
const wrapper = render(
<TestProviders>
<PickEventType {...defaultProps} />
</TestProviders>
);
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(
<TestProviders store={store2}>
<PickEventType {...defaultProps} />
</TestProviders>
);
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(
<TestProviders>
<PickEventType {...defaultProps} />
</TestProviders>
);
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(
<TestProviders>
<PickEventType {...defaultProps} />
</TestProviders>
);
const tooltip = wrapper.getByTestId('timeline-sourcerer-tooltip');
expect(within(tooltip).getByTestId('sourcerer-timeline-trigger')).toBeTruthy();
});
it('correctly filters options', () => {
const wrapper = render(
<TestProviders store={store}>
<PickEventType {...defaultProps} />
</TestProviders>
);
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(
<TestProviders store={store}>
<PickEventType {...defaultProps} />
</TestProviders>
);
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('')
);
});
});

View File

@ -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: (
<AllEuiHealth color="subdued">
<WarningEuiHealth color="warning">{i18n.ALL_EVENT}</WarningEuiHealth>
</AllEuiHealth>
),
},
{
id: 'raw',
label: <EuiHealth color="subdued"> {i18n.RAW_EVENT}</EuiHealth>,
disabled: !isDefaultPattern,
},
{
id: 'alert',
label: <EuiHealth color="warning"> {i18n.DETECTION_ALERTS_EVENT}</EuiHealth>,
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<PickEventTypeProps> = ({
eventType = 'all',
onChangeEventTypeAndIndexesName,
}) => {
const [isPopoverOpen, setPopover] = useState(false);
const [showAdvanceSettings, setAdvanceSettings] = useState(eventType === 'custom');
const [filterEventType, setFilterEventType] = useState<TimelineEventsType>(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<string>(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<Array<EuiComboBoxOptionOption<string>>>(
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<EuiComboBoxOptionOption<string>>) => {
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 ? (
<span data-test-subj="dataView-option-super">
<EuiIcon type="logoSecurity" size="s" /> {SIEM_DATA_VIEW_LABEL}
</span>
) : (
<span data-test-subj="dataView-option-super">
<EuiIcon type="logoKibana" size="s" /> {title}
</span>
),
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 (
<MyEuiButton
data-test-subj="sourcerer-timeline-trigger"
iconType="arrowDown"
iconSide="right"
isLoading={loading}
onClick={togglePopover}
>
{options.find((opt) => opt.id === eventType)?.label}
</MyEuiButton>
);
}, [defaultDataView.id, eventType, dataViewId, loading, togglePopover]);
const tooltipContent = useMemo(
() => (isPopoverOpen ? null : selectedPatterns.sort().join(', ')),
[isPopoverOpen, selectedPatterns]
);
const buttonWithTooptip = useMemo(() => {
return tooltipContent ? (
<EuiToolTip
position="top"
content={tooltipContent}
data-test-subj="timeline-sourcerer-tooltip"
>
{button}
</EuiToolTip>
) : (
button
);
}, [button, tooltipContent]);
const ButtonContent = useMemo(
() => (
<AdvancedSettings data-test-subj="advanced-settings">
{showAdvanceSettings
? i18n.HIDE_INDEX_PATTERNS_ADVANCED_SETTINGS
: i18n.SHOW_INDEX_PATTERNS_ADVANCED_SETTINGS}
</AdvancedSettings>
),
[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 (
<PickEventContainer>
<EuiPopover
button={buttonWithTooptip}
closePopover={closePopover}
id="popover"
isOpen={isPopoverOpen}
repositionOnScroll
>
<PopoverContent>
<EuiPopoverTitle>
<>{i18n.SELECT_INDEX_PATTERNS}</>
</EuiPopoverTitle>
<EuiSpacer size="s" />
<Filter
data-test-subj="timeline-sourcerer-radio"
options={filterOptions}
idSelected={filterEventType}
onChange={onChangeFilter}
name={i18n.SELECT_INDEX_PATTERNS}
/>
<EuiSpacer size="m" />
<EuiAccordion
data-test-subj="sourcerer-accordion"
id="accordion1"
forceState={showAdvanceSettings ? 'open' : 'closed'}
buttonContent={ButtonContent}
onToggle={setAdvanceSettings}
>
<>
<EuiSpacer size="s" />
<EuiSuperSelect
data-test-subj="sourcerer-select"
placeholder={i18n.PICK_INDEX_PATTERNS}
fullWidth
options={dataViewSelectOptions}
valueOfSelected={dataViewId}
onChange={onChangeSuper}
/>
<EuiSpacer size="xs" />
<EuiComboBox
data-test-subj="timeline-sourcerer"
fullWidth
onChange={onChangeCombo}
options={selectableOptions}
placeholder={i18n.PICK_INDEX_PATTERNS}
selectedOptions={selectedOptions}
/>
</>
</EuiAccordion>
{!showAdvanceSettings && (
<>
<EuiSpacer size="s" />
<ConfigHelper size="s" color="subdued">
{i18n.CONFIGURE_INDEX_PATTERNS}
</ConfigHelper>
</>
)}
<EuiSpacer size="m" />
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem>
<ResetButton
aria-label={i18n.DATA_SOURCES_RESET}
data-test-subj="sourcerer-reset"
flush="left"
onClick={resetDataSources}
title={i18n.DATA_SOURCES_RESET}
>
{i18n.DATA_SOURCES_RESET}
</ResetButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={handleSaveIndices}
data-test-subj="sourcerer-save"
disabled={isSavingDisabled}
fill
fullWidth
size="s"
>
{i18n.SAVE_INDEX_PATTERNS}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</PopoverContent>
</EuiPopover>
</PickEventContainer>
);
};
export const PickEventType = memo(PickEventTypeComponents);

View File

@ -46,7 +46,7 @@ export const TimelinesPageComponent: React.FC = () => {
{indicesExist ? (
<>
<SecuritySolutionPageWrapper>
<HeaderPage hideSourcerer={true} title={i18n.PAGE_TITLE}>
<HeaderPage title={i18n.PAGE_TITLE}>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem>
{capabilitiesCanUserCRUD && (
@ -93,6 +93,7 @@ export const TimelinesPageComponent: React.FC = () => {
</>
) : (
<SecuritySolutionPageWrapper>
<HeaderPage border title={i18n.PAGE_TITLE} />
<OverviewEmpty />
</SecuritySolutionPageWrapper>
)}

View File

@ -99,7 +99,6 @@ const UebaDetailsComponent: React.FC<UebaDetailsProps> = ({ detailName, uebaDeta
<Display show={!globalFullScreen}>
<HeaderPage
border
sourcererScope={SourcererScopeName.detections}
subtitle={
<LastEventTime
docValueFields={docValueFields}

View File

@ -22076,13 +22076,10 @@
"xpack.securitySolution.hostsTable.osTitle": "オペレーティングシステム",
"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 > 高度な設定で構成できます。",

View File

@ -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”>“高级设置”中配置这些索引。",