diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 0fc42895050a5..e5ccbb71c2b76 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -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'; diff --git a/x-pack/plugins/security_solution/public/app/app.tsx b/x-pack/plugins/security_solution/public/app/app.tsx index 147efb9e0d2e2..b5e952b0ffa8e 100644 --- a/x-pack/plugins/security_solution/public/app/app.tsx +++ b/x-pack/plugins/security_solution/public/app/app.tsx @@ -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; @@ -54,11 +54,13 @@ const StartAppComponent: FC = ({ children, apolloClient, hist - - - {children} - - + + + + {children} + + + diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx new file mode 100644 index 0000000000000..c330bb073b146 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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 + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/index.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.tsx new file mode 100644 index 0000000000000..6275ce19c3608 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.tsx @@ -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( + () => ( + + {i18n.SOURCERER} + + ), + [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) => ( + <> + {option.label} + {option.disabled ? ( + + ) : null} + + ), + [] + ); + const onChange = useCallback( + (choices: EuiSelectableOption[]) => { + const choice = choices.reduce( + (acc, { checked, label }) => (checked === 'on' ? [...acc, label] : acc), + [] + ); + onChangeIndexPattern(choice); + }, + [onChangeIndexPattern] + ); + const allOptions = useMemo(() => [...options, ...unSelectableOptions], [ + options, + unSelectableOptions, + ]); + return ( + setPopoverIsOpen(false)} + display="block" + panelPaddingSize="s" + ownFocus + > +
+ + <> + {i18n.CHANGE_INDEX_PATTERNS} + + + + + {(list, search) => ( + <> + {search} + {list} + + )} + + + + {i18n.ADD_INDEX_PATTERNS} + + +
+
+ ); +}); +MaybeSourcerer.displayName = 'Sourcerer'; + +export const Sourcerer = SOURCERER_FEATURE_FLAG_ON ? MaybeSourcerer : () => null; diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/translations.ts b/x-pack/plugins/security_solution/public/common/components/sourcerer/translations.ts new file mode 100644 index 0000000000000..71b1734dad6a6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/translations.ts @@ -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', + } +); diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/constants.ts b/x-pack/plugins/security_solution/public/common/containers/sourcerer/constants.ts new file mode 100644 index 0000000000000..106294ba54f5a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/constants.ts @@ -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-*', + ], +}; diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/format.test.tsx b/x-pack/plugins/security_solution/public/common/containers/sourcerer/format.test.tsx new file mode 100644 index 0000000000000..b8017df09b738 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/format.test.tsx @@ -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(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/format.ts b/x-pack/plugins/security_solution/public/common/containers/sourcerer/format.ts new file mode 100644 index 0000000000000..8c9a16ed705ef --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/format.ts @@ -0,0 +1,96 @@ +/* + * 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 { isEmpty, pick } from 'lodash/fp'; +import memoizeOne from 'memoize-one'; +import { set } from '@elastic/safer-lodash-set/fp'; +import { isUndefined } from 'lodash'; +import { IndexField } from '../../../graphql/types'; +import { IIndexPattern } from '../../../../../../../src/plugins/data/common/index_patterns'; + +export interface BrowserField { + aggregatable: boolean; + category: string; + description: string | null; + example: string | number | null; + fields: Readonly>>; + format: string; + indexes: string[]; + name: string; + searchable: boolean; + type: string; +} + +export interface DocValueFields { + field: string; + format: string; +} + +export type BrowserFields = Readonly>>; + +export const getAllBrowserFields = (browserFields: BrowserFields): Array> => + Object.values(browserFields).reduce>>( + (acc, namespace) => [ + ...acc, + ...Object.values(namespace.fields != null ? namespace.fields : {}), + ], + [] + ); + +export const getIndexFields = memoizeOne( + (title: string, fields: IndexField[]): IIndexPattern => + fields && fields.length > 0 + ? { + fields: fields.map((field) => + pick(['name', 'searchable', 'type', 'aggregatable', 'esTypes', 'subType'], field) + ), + title, + } + : { fields: [], title }, + (newArgs, lastArgs) => newArgs[0] === lastArgs[0] && newArgs[1].length === lastArgs[1].length +); + +export const getBrowserFields = memoizeOne( + (_title: string, fields: IndexField[]): BrowserFields => + fields && fields.length > 0 + ? fields.reduce( + (accumulator: BrowserFields, field: IndexField) => + set([field.category, 'fields', field.name], field, accumulator), + {} + ) + : {}, + // Update the value only if _title has changed + (newArgs, lastArgs) => newArgs[0] === lastArgs[0] +); + +export const getDocValueFields = memoizeOne( + (_title: string, fields: IndexField[]): DocValueFields[] => + fields && fields.length > 0 + ? fields.reduce((accumulator: DocValueFields[], field: IndexField) => { + if (field.type === 'date' && accumulator.length < 100) { + const format: string = + field.format != null && !isEmpty(field.format) ? field.format : 'date_time'; + return [ + ...accumulator, + { + field: field.name, + format, + }, + ]; + } + return accumulator; + }, []) + : [], + // Update the value only if _title has changed + (newArgs, lastArgs) => newArgs[0] === lastArgs[0] +); + +export const indicesExistOrDataTemporarilyUnavailable = ( + indicesExist: boolean | null | undefined +) => indicesExist || isUndefined(indicesExist); + +export const EMPTY_BROWSER_FIELDS = {}; +export const EMPTY_DOCVALUE_FIELD: DocValueFields[] = []; diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx new file mode 100644 index 0000000000000..38af84e0968f8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.test.tsx @@ -0,0 +1,181 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; + +import { getSourceDefaults, useSourceManager, UseSourceManager } from '.'; +import { + mockSourceSelections, + mockSourceGroup, + mockSourceGroups, + mockPatterns, + mockSource, +} from './mocks'; +import { SecurityPageName } from './constants'; +const mockSourceDefaults = mockSource(SecurityPageName.default); +jest.mock('../../lib/kibana', () => ({ + useKibana: jest.fn().mockReturnValue({ + services: { + data: { + indexPatterns: { + getTitles: jest.fn().mockImplementation(() => Promise.resolve(mockPatterns)), + }, + }, + }, + }), +})); +jest.mock('../../utils/apollo_context', () => ({ + useApolloClient: jest.fn().mockReturnValue({ + query: jest.fn().mockImplementation(() => Promise.resolve(mockSourceDefaults)), + }), +})); + +describe('Sourcerer Hooks', () => { + const testId = SecurityPageName.default; + const uninitializedId = SecurityPageName.host; + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + describe('Initialization', () => { + it('initializes loading default index patterns', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useSourceManager() + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + activeSourceGroupId: 'default', + availableIndexPatterns: [], + availableSourceGroupIds: [], + isIndexPatternsLoading: true, + sourceGroups: {}, + getManageSourceGroupById: result.current.getManageSourceGroupById, + initializeSourceGroup: result.current.initializeSourceGroup, + setActiveSourceGroupId: result.current.setActiveSourceGroupId, + updateSourceGroupIndicies: result.current.updateSourceGroupIndicies, + }); + }); + }); + it('initializes loading default source group', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useSourceManager() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + activeSourceGroupId: 'default', + availableIndexPatterns: mockPatterns, + availableSourceGroupIds: [], + isIndexPatternsLoading: false, + sourceGroups: {}, + getManageSourceGroupById: result.current.getManageSourceGroupById, + initializeSourceGroup: result.current.initializeSourceGroup, + setActiveSourceGroupId: result.current.setActiveSourceGroupId, + updateSourceGroupIndicies: result.current.updateSourceGroupIndicies, + }); + }); + }); + it('initialize completes with formatted source group data', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useSourceManager() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + activeSourceGroupId: testId, + availableIndexPatterns: mockPatterns, + availableSourceGroupIds: [testId], + isIndexPatternsLoading: false, + sourceGroups: { + default: mockSourceGroup(testId), + }, + getManageSourceGroupById: result.current.getManageSourceGroupById, + initializeSourceGroup: result.current.initializeSourceGroup, + setActiveSourceGroupId: result.current.setActiveSourceGroupId, + updateSourceGroupIndicies: result.current.updateSourceGroupIndicies, + }); + }); + }); + }); + describe('Methods', () => { + it('getManageSourceGroupById: initialized source group returns defaults', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useSourceManager() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + const initializedSourceGroup = result.current.getManageSourceGroupById(testId); + expect(initializedSourceGroup).toEqual(mockSourceGroup(testId)); + }); + }); + it('getManageSourceGroupById: uninitialized source group returns defaults', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useSourceManager() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + const uninitializedSourceGroup = result.current.getManageSourceGroupById(uninitializedId); + expect(uninitializedSourceGroup).toEqual(getSourceDefaults(uninitializedId, mockPatterns)); + }); + }); + it('initializeSourceGroup: initializes source group', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useSourceManager() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + result.current.initializeSourceGroup( + uninitializedId, + mockSourceGroups[uninitializedId], + true + ); + await waitForNextUpdate(); + const initializedSourceGroup = result.current.getManageSourceGroupById(uninitializedId); + expect(initializedSourceGroup.indexPatterns).toEqual(mockSourceSelections[uninitializedId]); + }); + }); + it('setActiveSourceGroupId: active source group id gets set only if it gets initialized first', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useSourceManager() + ); + await waitForNextUpdate(); + expect(result.current.activeSourceGroupId).toEqual(testId); + result.current.setActiveSourceGroupId(uninitializedId); + expect(result.current.activeSourceGroupId).toEqual(testId); + result.current.initializeSourceGroup(uninitializedId); + result.current.setActiveSourceGroupId(uninitializedId); + expect(result.current.activeSourceGroupId).toEqual(uninitializedId); + }); + }); + it('updateSourceGroupIndicies: updates source group indicies', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useSourceManager() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + let sourceGroup = result.current.getManageSourceGroupById(testId); + expect(sourceGroup.indexPatterns).toEqual(mockSourceSelections[testId]); + result.current.updateSourceGroupIndicies(testId, ['endgame-*', 'filebeat-*']); + await waitForNextUpdate(); + sourceGroup = result.current.getManageSourceGroupById(testId); + expect(sourceGroup.indexPatterns).toEqual(['endgame-*', 'filebeat-*']); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx new file mode 100644 index 0000000000000..91907b45aa449 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx @@ -0,0 +1,415 @@ +/* + * 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 { get, noop, isEmpty } from 'lodash/fp'; +import React, { createContext, useCallback, useContext, useEffect, useReducer } from 'react'; +import { IIndexPattern } from 'src/plugins/data/public'; + +import { NO_ALERT_INDEX } from '../../../../common/constants'; +import { useKibana } from '../../lib/kibana'; + +import { SourceQuery } from '../../../graphql/types'; + +import { sourceQuery } from '../source/index.gql_query'; +import { useApolloClient } from '../../utils/apollo_context'; +import { + sourceGroups, + SecurityPageName, + SourceGroupsType, + SOURCERER_FEATURE_FLAG_ON, +} from './constants'; +import { + BrowserFields, + DocValueFields, + EMPTY_BROWSER_FIELDS, + EMPTY_DOCVALUE_FIELD, + getBrowserFields, + getDocValueFields, + getIndexFields, + indicesExistOrDataTemporarilyUnavailable, +} from './format'; + +// TYPES +interface ManageSource { + browserFields: BrowserFields; + defaultPatterns: string[]; + docValueFields: DocValueFields[]; + errorMessage: string | null; + id: SourceGroupsType; + indexPattern: IIndexPattern; + indexPatterns: string[]; + indicesExist: boolean | undefined | null; + loading: boolean; +} + +interface ManageSourceInit extends Partial { + id: SourceGroupsType; +} + +type ManageSourceGroupById = { + [id in SourceGroupsType]?: ManageSource; +}; + +type ActionManageSource = + | { + type: 'SET_SOURCE'; + id: SourceGroupsType; + defaultIndex: string[]; + payload: ManageSourceInit; + } + | { + type: 'SET_IS_SOURCE_LOADING'; + id: SourceGroupsType; + payload: boolean; + } + | { + type: 'SET_ACTIVE_SOURCE_GROUP_ID'; + payload: SourceGroupsType; + } + | { + type: 'SET_AVAILABLE_INDEX_PATTERNS'; + payload: string[]; + } + | { + type: 'SET_IS_INDEX_PATTERNS_LOADING'; + payload: boolean; + }; + +interface ManageSourcerer { + activeSourceGroupId: SourceGroupsType; + availableIndexPatterns: string[]; + availableSourceGroupIds: SourceGroupsType[]; + isIndexPatternsLoading: boolean; + sourceGroups: ManageSourceGroupById; +} + +export interface UseSourceManager extends ManageSourcerer { + getManageSourceGroupById: (id: SourceGroupsType) => ManageSource; + initializeSourceGroup: ( + id: SourceGroupsType, + indexToAdd?: string[] | null, + onlyCheckIndexToAdd?: boolean + ) => void; + setActiveSourceGroupId: (id: SourceGroupsType) => void; + updateSourceGroupIndicies: (id: SourceGroupsType, updatedIndicies: string[]) => void; +} + +// DEFAULTS/INIT +export const getSourceDefaults = (id: SourceGroupsType, defaultIndex: string[]) => ({ + browserFields: EMPTY_BROWSER_FIELDS, + defaultPatterns: defaultIndex, + docValueFields: EMPTY_DOCVALUE_FIELD, + errorMessage: null, + id, + indexPattern: getIndexFields(defaultIndex.join(), []), + indexPatterns: defaultIndex, + indicesExist: indicesExistOrDataTemporarilyUnavailable(undefined), + loading: true, +}); + +const initManageSource: ManageSourcerer = { + activeSourceGroupId: SecurityPageName.default, + availableIndexPatterns: [], + availableSourceGroupIds: [], + isIndexPatternsLoading: true, + sourceGroups: {}, +}; +const init: UseSourceManager = { + ...initManageSource, + getManageSourceGroupById: (id: SourceGroupsType) => getSourceDefaults(id, []), + initializeSourceGroup: () => noop, + setActiveSourceGroupId: () => noop, + updateSourceGroupIndicies: () => noop, +}; + +const reducerManageSource = (state: ManageSourcerer, action: ActionManageSource) => { + switch (action.type) { + case 'SET_SOURCE': + return { + ...state, + sourceGroups: { + ...state.sourceGroups, + [action.id]: { + ...getSourceDefaults(action.id, action.defaultIndex), + ...state.sourceGroups[action.id], + ...action.payload, + }, + }, + availableSourceGroupIds: state.availableSourceGroupIds.includes(action.id) + ? state.availableSourceGroupIds + : [...state.availableSourceGroupIds, action.id], + }; + case 'SET_IS_SOURCE_LOADING': + return { + ...state, + sourceGroups: { + ...state.sourceGroups, + [action.id]: { + ...state.sourceGroups[action.id], + id: action.id, + loading: action.payload, + }, + }, + }; + case 'SET_ACTIVE_SOURCE_GROUP_ID': + return { + ...state, + activeSourceGroupId: action.payload, + }; + case 'SET_AVAILABLE_INDEX_PATTERNS': + return { + ...state, + availableIndexPatterns: action.payload, + }; + case 'SET_IS_INDEX_PATTERNS_LOADING': + return { + ...state, + isIndexPatternsLoading: action.payload, + }; + default: + return state; + } +}; + +// HOOKS +export const useSourceManager = (): UseSourceManager => { + const { + services: { + data: { indexPatterns }, + }, + } = useKibana(); + const apolloClient = useApolloClient(); + const [state, dispatch] = useReducer(reducerManageSource, initManageSource); + + // Kibana Index Patterns + const setIsIndexPatternsLoading = useCallback((loading: boolean) => { + dispatch({ + type: 'SET_IS_INDEX_PATTERNS_LOADING', + payload: loading, + }); + }, []); + const getDefaultIndex = useCallback( + (indexToAdd?: string[] | null, onlyCheckIndexToAdd?: boolean) => { + const filterIndexAdd = (indexToAdd ?? []).filter((item) => item !== NO_ALERT_INDEX); + if (!isEmpty(filterIndexAdd)) { + return onlyCheckIndexToAdd + ? filterIndexAdd.sort() + : [ + ...state.availableIndexPatterns, + ...filterIndexAdd.filter((index) => !state.availableIndexPatterns.includes(index)), + ].sort(); + } + return state.availableIndexPatterns.sort(); + }, + [state.availableIndexPatterns] + ); + const setAvailableIndexPatterns = useCallback((availableIndexPatterns: string[]) => { + dispatch({ + type: 'SET_AVAILABLE_INDEX_PATTERNS', + payload: availableIndexPatterns, + }); + }, []); + const fetchKibanaIndexPatterns = useCallback(() => { + setIsIndexPatternsLoading(true); + const abortCtrl = new AbortController(); + + async function fetchTitles() { + try { + const result = await indexPatterns.getTitles(); + setAvailableIndexPatterns(result); + setIsIndexPatternsLoading(false); + } catch (error) { + setIsIndexPatternsLoading(false); + } + } + + fetchTitles(); + + return () => { + return abortCtrl.abort(); + }; + }, [indexPatterns, setAvailableIndexPatterns, setIsIndexPatternsLoading]); + + // Security Solution Source Groups + const setActiveSourceGroupId = useCallback( + (sourceGroupId: SourceGroupsType) => { + if (state.availableSourceGroupIds.includes(sourceGroupId)) { + dispatch({ + type: 'SET_ACTIVE_SOURCE_GROUP_ID', + payload: sourceGroupId, + }); + } + }, + [state.availableSourceGroupIds] + ); + const setIsSourceLoading = useCallback( + ({ id, loading }: { id: SourceGroupsType; loading: boolean }) => { + dispatch({ + type: 'SET_IS_SOURCE_LOADING', + id, + payload: loading, + }); + }, + [] + ); + const enrichSource = useCallback( + (id: SourceGroupsType, indexToAdd?: string[] | null, onlyCheckIndexToAdd?: boolean) => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + const defaultIndex = getDefaultIndex(indexToAdd, onlyCheckIndexToAdd); + const selectedPatterns = defaultIndex.filter((pattern) => + state.availableIndexPatterns.includes(pattern) + ); + if (state.sourceGroups[id] == null) { + dispatch({ + type: 'SET_SOURCE', + id, + defaultIndex: selectedPatterns, + payload: { defaultPatterns: defaultIndex, id }, + }); + } + + async function fetchSource() { + if (!apolloClient) return; + setIsSourceLoading({ id, loading: true }); + try { + const result = await apolloClient.query({ + query: sourceQuery, + fetchPolicy: 'network-only', + variables: { + sourceId: 'default', // always + defaultIndex: selectedPatterns, + }, + context: { + fetchOptions: { + signal: abortCtrl.signal, + }, + }, + }); + if (isSubscribed) { + dispatch({ + type: 'SET_SOURCE', + id, + defaultIndex: selectedPatterns, + payload: { + browserFields: getBrowserFields( + selectedPatterns.join(), + get('data.source.status.indexFields', result) + ), + docValueFields: getDocValueFields( + selectedPatterns.join(), + get('data.source.status.indexFields', result) + ), + errorMessage: null, + id, + indexPattern: getIndexFields( + selectedPatterns.join(), + get('data.source.status.indexFields', result) + ), + indexPatterns: selectedPatterns, + indicesExist: indicesExistOrDataTemporarilyUnavailable( + get('data.source.status.indicesExist', result) + ), + loading: false, + }, + }); + } + } catch (error) { + if (isSubscribed) { + dispatch({ + type: 'SET_SOURCE', + id, + defaultIndex: selectedPatterns, + payload: { + errorMessage: error.message, + id, + loading: false, + }, + }); + } + } + } + + fetchSource(); + + return () => { + isSubscribed = false; + return abortCtrl.abort(); + }; + }, + [ + apolloClient, + getDefaultIndex, + setIsSourceLoading, + state.availableIndexPatterns, + state.sourceGroups, + ] + ); + + const initializeSourceGroup = useCallback( + (id: SourceGroupsType, indexToAdd?: string[] | null, onlyCheckIndexToAdd?: boolean) => + enrichSource(id, indexToAdd, onlyCheckIndexToAdd), + [enrichSource] + ); + + const updateSourceGroupIndicies = useCallback( + (id: SourceGroupsType, updatedIndicies: string[]) => enrichSource(id, updatedIndicies, true), + [enrichSource] + ); + const getManageSourceGroupById = useCallback( + (id: SourceGroupsType) => { + const sourceById = state.sourceGroups[id]; + if (sourceById != null) { + return sourceById; + } + return getSourceDefaults(id, getDefaultIndex()); + }, + [getDefaultIndex, state.sourceGroups] + ); + + // load initial default index + useEffect(() => { + fetchKibanaIndexPatterns(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!state.isIndexPatternsLoading) { + Object.entries(sourceGroups).forEach(([key, value]) => + initializeSourceGroup(key as SourceGroupsType, value, true) + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.isIndexPatternsLoading]); + + return { + ...state, + getManageSourceGroupById, + initializeSourceGroup, + setActiveSourceGroupId, + updateSourceGroupIndicies, + }; +}; + +const ManageSourceContext = createContext(init); + +export const useManageSource = () => useContext(ManageSourceContext); + +interface ManageSourceProps { + children: React.ReactNode; +} + +export const MaybeManageSource = ({ children }: ManageSourceProps) => { + const indexPatternManager = useSourceManager(); + return ( + + {children} + + ); +}; +export const ManageSource = SOURCERER_FEATURE_FLAG_ON + ? MaybeManageSource + : ({ children }: ManageSourceProps) => <>{children}; diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/mocks.ts b/x-pack/plugins/security_solution/public/common/containers/sourcerer/mocks.ts new file mode 100644 index 0000000000000..cde14e54694f0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/mocks.ts @@ -0,0 +1,114 @@ +/* + * 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 { SecurityPageName } from './constants'; +import { getSourceDefaults } from './index'; + +export const mockPatterns = [ + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', +]; + +export const mockSourceGroups = { + [SecurityPageName.default]: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'blobbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'winlogbeat-*', + ], + [SecurityPageName.host]: [ + 'apm-*-transaction*', + 'endgame-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + ], +}; + +export const mockSourceSelections = { + [SecurityPageName.default]: ['auditbeat-*', 'endgame-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'], + [SecurityPageName.host]: ['endgame-*', 'logs-*', 'packetbeat-*', 'winlogbeat-*'], +}; +export const mockSource = (testId: SecurityPageName.default | SecurityPageName.host) => ({ + data: { + source: { + id: 'default', + status: { + indicesExist: true, + indexFields: [ + { + category: '_id', + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + indexes: mockSourceSelections[testId], + name: '_id', + searchable: true, + type: 'string', + aggregatable: false, + format: null, + esTypes: null, + subType: null, + __typename: 'IndexField', + }, + ], + }, + }, + }, + loading: false, + networkStatus: 7, + stale: false, +}); + +export const mockSourceGroup = (testId: SecurityPageName.default | SecurityPageName.host) => { + const indexes = mockSourceSelections[testId]; + return { + ...getSourceDefaults(testId, mockPatterns), + defaultPatterns: mockSourceGroups[testId], + browserFields: { + _id: { + fields: { + _id: { + __typename: 'IndexField', + aggregatable: false, + category: '_id', + description: 'Each document has an _id that uniquely identifies it', + esTypes: null, + example: 'Y-6TfmcB0WOhS6qyMv3s', + format: null, + indexes, + name: '_id', + searchable: true, + subType: null, + type: 'string', + }, + }, + }, + }, + indexPattern: { + fields: [ + { + aggregatable: false, + esTypes: null, + name: '_id', + searchable: true, + subType: null, + type: 'string', + }, + ], + title: indexes.join(), + }, + indexPatterns: indexes, + indicesExist: true, + loading: false, + }; +}; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx index 583c76d1464a8..783e433dfba26 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx @@ -22,6 +22,7 @@ import { InspectButtonContainer } from '../../../common/components/inspect'; import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; import { SecurityPageName } from '../../../app/types'; import { LinkButton } from '../../../common/components/links'; +import { Sourcerer } from '../../../common/components/sourcerer'; export interface OwnProps { startDate: GlobalTimeArgs['from']; @@ -107,7 +108,10 @@ const OverviewHostComponent: React.FC = ({ /> } > - {hostPageButton} + <> + + {hostPageButton} + ( ({ id = ID, children, filterQuery, isInspected, sourceId, startDate, endDate }) => { + const { activeSourceGroupId, getManageSourceGroupById } = useManageSource(); + const { indexPatterns } = useMemo(() => getManageSourceGroupById(activeSourceGroupId), [ + getManageSourceGroupById, + activeSourceGroupId, + ]); + const uiDefaultIndexPatterns = useUiSetting(DEFAULT_INDEX_KEY); + const defaultIndex = SOURCERER_FEATURE_FLAG_ON ? indexPatterns : uiDefaultIndexPatterns; return ( query={overviewHostQuery} @@ -50,7 +59,7 @@ const OverviewHostComponentQuery = React.memo(DEFAULT_INDEX_KEY), + defaultIndex, inspect: isInspected, }} >