Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Security solution] Sourcerer: Kibana index pattern selector for security views #74706

Merged
merged 13 commits into from
Aug 14, 2020
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 { SOURCE_GROUPS } 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: SOURCE_GROUPS.default,
availableIndexPatterns: mockPatterns,
availableSourceGroupIds: [SOURCE_GROUPS.default],
getManageSourceGroupById: jest.fn().mockReturnValue(mockSourceGroup(SOURCE_GROUPS.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(SOURCE_GROUPS.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,160 @@
/*
* 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 trigger = useMemo(
() => (
<EuiButtonEmpty
aria-label={i18n.SOURCERER}
data-test-subj="sourcerer-trigger"
flush="left"
iconSide="right"
iconType="indexSettings"
onClick={() => setPopoverIsOpen(!isPopoverOpen)}
stephmilovic marked this conversation as resolved.
Show resolved Hide resolved
size="l"
title={i18n.SOURCERER}
>
{i18n.SOURCERER}
</EuiButtonEmpty>
),
[isPopoverOpen]
);
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 SOURCE_GROUPS {
stephmilovic marked this conversation as resolved.
Show resolved Hide resolved
default = 'default',
host = 'host',
detections = 'detections',
timeline = 'timeline',
network = 'network',
}

export type SourceGroupsType = keyof typeof SOURCE_GROUPS;

export const sourceGroups = {
[SOURCE_GROUPS.default]: [
'apm-*-transaction*',
'auditbeat-*',
'endgame-*',
'filebeat-*',
'logs-*',
'winlogbeat-*',
'blobbeat-*',
],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { indicesExistOrDataTemporarilyUnavailable } from './format';

describe('indicesExistOrDataTemporarilyUnavailable', () => {
it('it returns true when undefined', () => {
let undefVar;
const result = indicesExistOrDataTemporarilyUnavailable(undefVar);
expect(result).toBeTruthy();
});
it('it returns true when true', () => {
const result = indicesExistOrDataTemporarilyUnavailable(true);
expect(result).toBeTruthy();
});
it('it returns false when false', () => {
const result = indicesExistOrDataTemporarilyUnavailable(false);
expect(result).toBeFalsy();
});
});
Loading