Skip to content

Commit

Permalink
[Security solution] Sourcerer: Kibana index pattern selector for secu…
Browse files Browse the repository at this point in the history
…rity views (#74706) (#75128)
  • Loading branch information
stephmilovic authored Aug 17, 2020
1 parent 02cb740 commit 8cf8944
Show file tree
Hide file tree
Showing 13 changed files with 1,191 additions and 9 deletions.
1 change: 1 addition & 0 deletions x-pack/plugins/security_solution/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const APP_NAME = 'Security';
export const APP_ICON = 'securityAnalyticsApp';
export const APP_PATH = `/app/security`;
export const ADD_DATA_PATH = `/app/home#/tutorial_directory/security`;
export const ADD_INDEX_PATH = `/app/management/kibana/indexPatterns/create`;
export const DEFAULT_BYTES_FORMAT = 'format:bytes:defaultPattern';
export const DEFAULT_DATE_FORMAT = 'dateFormat';
export const DEFAULT_DATE_FORMAT_TZ = 'dateFormat:tz';
Expand Down
14 changes: 8 additions & 6 deletions x-pack/plugins/security_solution/public/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { ApolloClientContext } from '../common/utils/apollo_context';
import { ManageGlobalTimeline } from '../timelines/components/manage_timeline';
import { StartServices } from '../types';
import { PageRouter } from './routes';

import { ManageSource } from '../common/containers/sourcerer';
interface StartAppComponent extends AppFrontendLibs {
children: React.ReactNode;
history: History;
Expand All @@ -54,11 +54,13 @@ const StartAppComponent: FC<StartAppComponent> = ({ children, apolloClient, hist
<ReduxStoreProvider store={store}>
<ApolloProvider client={apolloClient}>
<ApolloClientContext.Provider value={apolloClient}>
<ThemeProvider theme={theme}>
<MlCapabilitiesProvider>
<PageRouter history={history}>{children}</PageRouter>
</MlCapabilitiesProvider>
</ThemeProvider>
<ManageSource>
<ThemeProvider theme={theme}>
<MlCapabilitiesProvider>
<PageRouter history={history}>{children}</PageRouter>
</MlCapabilitiesProvider>
</ThemeProvider>
</ManageSource>
<ErrorToastDispatcher />
<GlobalToaster />
</ApolloClientContext.Provider>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import React from 'react';
import { mount } from 'enzyme';
import { SecurityPageName } from '../../containers/sourcerer/constants';
import { mockPatterns, mockSourceGroup } from '../../containers/sourcerer/mocks';
import { MaybeSourcerer } from './index';
import * as i18n from './translations';
import { ADD_INDEX_PATH } from '../../../../common/constants';

const updateSourceGroupIndicies = jest.fn();
const mockManageSource = {
activeSourceGroupId: SecurityPageName.default,
availableIndexPatterns: mockPatterns,
availableSourceGroupIds: [SecurityPageName.default],
getManageSourceGroupById: jest.fn().mockReturnValue(mockSourceGroup(SecurityPageName.default)),
initializeSourceGroup: jest.fn(),
isIndexPatternsLoading: false,
setActiveSourceGroupId: jest.fn(),
updateSourceGroupIndicies,
};
jest.mock('../../containers/sourcerer', () => {
const original = jest.requireActual('../../containers/sourcerer');

return {
...original,
useManageSource: () => mockManageSource,
};
});

const mockOptions = [
{ label: 'auditbeat-*', key: 'auditbeat-*-0', value: 'auditbeat-*', checked: 'on' },
{ label: 'endgame-*', key: 'endgame-*-1', value: 'endgame-*', checked: 'on' },
{ label: 'filebeat-*', key: 'filebeat-*-2', value: 'filebeat-*', checked: 'on' },
{ label: 'logs-*', key: 'logs-*-3', value: 'logs-*', checked: 'on' },
{ label: 'packetbeat-*', key: 'packetbeat-*-4', value: 'packetbeat-*', checked: undefined },
{ label: 'winlogbeat-*', key: 'winlogbeat-*-5', value: 'winlogbeat-*', checked: 'on' },
{
label: 'apm-*-transaction*',
key: 'apm-*-transaction*-0',
value: 'apm-*-transaction*',
disabled: true,
checked: undefined,
},
{
label: 'blobbeat-*',
key: 'blobbeat-*-1',
value: 'blobbeat-*',
disabled: true,
checked: undefined,
},
];

describe('Sourcerer component', () => {
// Using props callback instead of simulating clicks,
// because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
it('Mounts with correct options selected and disabled', () => {
const wrapper = mount(<MaybeSourcerer />);
wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click');

expect(
wrapper.find(`[data-test-subj="indexPattern-switcher"]`).first().prop('options')
).toEqual(mockOptions);
});
it('onChange calls updateSourceGroupIndicies', () => {
const wrapper = mount(<MaybeSourcerer />);
wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click');

const switcherOnChange = wrapper
.find(`[data-test-subj="indexPattern-switcher"]`)
.first()
.prop('onChange');
// @ts-ignore
switcherOnChange([mockOptions[0], mockOptions[1]]);
expect(updateSourceGroupIndicies).toHaveBeenCalledWith(SecurityPageName.default, [
mockOptions[0].value,
mockOptions[1].value,
]);
});
it('Disabled options have icon tooltip', () => {
const wrapper = mount(<MaybeSourcerer />);
wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click');
// @ts-ignore
const Rendered = wrapper
.find(`[data-test-subj="indexPattern-switcher"]`)
.first()
.prop('renderOption')(
{
label: 'blobbeat-*',
key: 'blobbeat-*-1',
value: 'blobbeat-*',
disabled: true,
checked: undefined,
},
''
);
expect(Rendered.props.children[1].props.content).toEqual(i18n.DISABLED_INDEX_PATTERNS);
});

it('Button links to index path', () => {
const wrapper = mount(<MaybeSourcerer />);
wrapper.find(`[data-test-subj="sourcerer-trigger"]`).first().simulate('click');

expect(wrapper.find(`[data-test-subj="add-index"]`).first().prop('href')).toEqual(
ADD_INDEX_PATH
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import React, { useCallback, useMemo, useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiHighlight,
EuiIconTip,
EuiPopover,
EuiPopoverFooter,
EuiPopoverTitle,
EuiSelectable,
} from '@elastic/eui';
import { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option';
import { useManageSource } from '../../containers/sourcerer';
import * as i18n from './translations';
import { SOURCERER_FEATURE_FLAG_ON } from '../../containers/sourcerer/constants';
import { ADD_INDEX_PATH } from '../../../../common/constants';

export const MaybeSourcerer = React.memo(() => {
const {
activeSourceGroupId,
availableIndexPatterns,
getManageSourceGroupById,
isIndexPatternsLoading,
updateSourceGroupIndicies,
} = useManageSource();
const { defaultPatterns, indexPatterns: selectedOptions, loading: loadingIndices } = useMemo(
() => getManageSourceGroupById(activeSourceGroupId),
[getManageSourceGroupById, activeSourceGroupId]
);

const loading = useMemo(() => loadingIndices || isIndexPatternsLoading, [
isIndexPatternsLoading,
loadingIndices,
]);

const onChangeIndexPattern = useCallback(
(newIndexPatterns: string[]) => {
updateSourceGroupIndicies(activeSourceGroupId, newIndexPatterns);
},
[activeSourceGroupId, updateSourceGroupIndicies]
);

const [isPopoverOpen, setPopoverIsOpen] = useState(false);
const setPopoverIsOpenCb = useCallback(() => setPopoverIsOpen((prevState) => !prevState), []);
const trigger = useMemo(
() => (
<EuiButtonEmpty
aria-label={i18n.SOURCERER}
data-test-subj="sourcerer-trigger"
flush="left"
iconSide="right"
iconType="indexSettings"
onClick={setPopoverIsOpenCb}
size="l"
title={i18n.SOURCERER}
>
{i18n.SOURCERER}
</EuiButtonEmpty>
),
[setPopoverIsOpenCb]
);
const options: EuiSelectableOption[] = useMemo(
() =>
availableIndexPatterns.map((title, id) => ({
label: title,
key: `${title}-${id}`,
value: title,
checked: selectedOptions.includes(title) ? 'on' : undefined,
})),
[availableIndexPatterns, selectedOptions]
);
const unSelectableOptions: EuiSelectableOption[] = useMemo(
() =>
defaultPatterns
.filter((title) => !availableIndexPatterns.includes(title))
.map((title, id) => ({
label: title,
key: `${title}-${id}`,
value: title,
disabled: true,
checked: undefined,
})),
[availableIndexPatterns, defaultPatterns]
);
const renderOption = useCallback(
(option, searchValue) => (
<>
<EuiHighlight search={searchValue}>{option.label}</EuiHighlight>
{option.disabled ? (
<EuiIconTip position="top" content={i18n.DISABLED_INDEX_PATTERNS} />
) : null}
</>
),
[]
);
const onChange = useCallback(
(choices: EuiSelectableOption[]) => {
const choice = choices.reduce<string[]>(
(acc, { checked, label }) => (checked === 'on' ? [...acc, label] : acc),
[]
);
onChangeIndexPattern(choice);
},
[onChangeIndexPattern]
);
const allOptions = useMemo(() => [...options, ...unSelectableOptions], [
options,
unSelectableOptions,
]);
return (
<EuiPopover
button={trigger}
isOpen={isPopoverOpen}
closePopover={() => setPopoverIsOpen(false)}
display="block"
panelPaddingSize="s"
ownFocus
>
<div style={{ width: 320 }}>
<EuiPopoverTitle>
<>
{i18n.CHANGE_INDEX_PATTERNS}
<EuiIconTip position="right" content={i18n.CONFIGURE_INDEX_PATTERNS} />
</>
</EuiPopoverTitle>
<EuiSelectable
data-test-subj="indexPattern-switcher"
searchable
isLoading={loading}
options={allOptions}
onChange={onChange}
renderOption={renderOption}
searchProps={{
compressed: true,
}}
>
{(list, search) => (
<>
{search}
{list}
</>
)}
</EuiSelectable>
<EuiPopoverFooter>
<EuiButton data-test-subj="add-index" href={ADD_INDEX_PATH} fullWidth size="s">
{i18n.ADD_INDEX_PATTERNS}
</EuiButton>
</EuiPopoverFooter>
</div>
</EuiPopover>
);
});
MaybeSourcerer.displayName = 'Sourcerer';

export const Sourcerer = SOURCERER_FEATURE_FLAG_ON ? MaybeSourcerer : () => null;
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { i18n } from '@kbn/i18n';

export const SOURCERER = i18n.translate('xpack.securitySolution.indexPatterns.sourcerer', {
defaultMessage: 'Sourcerer',
});

export const CHANGE_INDEX_PATTERNS = i18n.translate('xpack.securitySolution.indexPatterns.help', {
defaultMessage: 'Change index patterns',
});

export const ADD_INDEX_PATTERNS = i18n.translate('xpack.securitySolution.indexPatterns.add', {
defaultMessage: 'Configure Kibana index patterns',
});

export const CONFIGURE_INDEX_PATTERNS = i18n.translate(
'xpack.securitySolution.indexPatterns.configure',
{
defaultMessage:
'Configure additional Kibana index patterns to see them become available in the Security Solution',
}
);

export const DISABLED_INDEX_PATTERNS = i18n.translate(
'xpack.securitySolution.indexPatterns.disabled',
{
defaultMessage:
'Disabled index patterns are recommended on this page, but first need to be configured in your Kibana index pattern settings',
}
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

export const SOURCERER_FEATURE_FLAG_ON = false;

export enum SecurityPageName {
default = 'default',
host = 'host',
detections = 'detections',
timeline = 'timeline',
network = 'network',
}

export type SourceGroupsType = keyof typeof SecurityPageName;

export const sourceGroups = {
[SecurityPageName.default]: [
'apm-*-transaction*',
'auditbeat-*',
'endgame-*',
'filebeat-*',
'logs-*',
'winlogbeat-*',
'blobbeat-*',
],
};
Loading

0 comments on commit 8cf8944

Please sign in to comment.