From fcd0d205d54f06e3481449ee46f499c9dbc6d688 Mon Sep 17 00:00:00 2001 From: Kevin Logan <56395104+kevinlog@users.noreply.github.com> Date: Wed, 1 Jul 2020 20:14:08 -0400 Subject: [PATCH] [ENDPOINT][SIEM] Display dismissible Endpoint notice on Overview page if no endpoints are deployed (#70122) (#70511) Co-authored-by: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> --- .../security_solution/common/constants.ts | 1 + .../use_messages_storage.test.tsx | 15 +++ .../local_storage/use_messages_storage.tsx | 10 ++ .../public/common/containers/source/index.tsx | 10 +- .../public/management/common/constants.ts | 3 + .../hooks/use_management_format_url.ts | 18 +++ .../configure_datasource.tsx | 3 +- .../pages/policy/view/policy_list.tsx | 5 +- .../components/endpoint_notice/index.tsx | 65 +++++++++++ .../public/overview/pages/overview.test.tsx | 108 ++++++++++++++++++ .../public/overview/pages/overview.tsx | 31 ++++- 11 files changed, 262 insertions(+), 7 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/components/hooks/use_management_format_url.ts create mode 100644 x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 4aff1c81c40f71..2adebac8590ca0 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -33,6 +33,7 @@ export const DEFAULT_INTERVAL_TYPE = 'manual'; export const DEFAULT_INTERVAL_VALUE = 300000; // ms export const DEFAULT_TIMEPICKER_QUICK_RANGES = 'timepicker:quickRanges'; export const NO_ALERT_INDEX = 'no-alert-index-049FC71A-4C2C-446F-9901-37XMC5024C51'; +export const ENDPOINT_METADATA_INDEX = 'metrics-endpoint.metadata-*'; export const APP_OVERVIEW_PATH = `${APP_PATH}/overview`; export const APP_ALERTS_PATH = `${APP_PATH}/alerts`; diff --git a/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.test.tsx b/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.test.tsx index d52bc4b1a267d9..7085894e4a51c3 100644 --- a/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.test.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.test.tsx @@ -69,6 +69,21 @@ describe('useLocalStorage', () => { }); }); + it('should return presence of a message', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useMessagesStorage() + ); + await waitForNextUpdate(); + const { hasMessage, addMessage, removeMessage } = result.current; + addMessage('case', 'id-1'); + addMessage('case', 'id-2'); + removeMessage('case', 'id-2'); + expect(hasMessage('case', 'id-1')).toEqual(true); + expect(hasMessage('case', 'id-2')).toEqual(false); + }); + }); + it('should clear all messages', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => diff --git a/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.tsx b/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.tsx index 0c96712ad9c53c..7b9c3f74a18dfc 100644 --- a/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.tsx @@ -12,6 +12,7 @@ export interface UseMessagesStorage { addMessage: (plugin: string, id: string) => void; removeMessage: (plugin: string, id: string) => void; clearAllMessages: (plugin: string) => void; + hasMessage: (plugin: string, id: string) => boolean; } export const useMessagesStorage = (): UseMessagesStorage => { @@ -30,6 +31,14 @@ export const useMessagesStorage = (): UseMessagesStorage => { [storage] ); + const hasMessage = useCallback( + (plugin: string, id: string): boolean => { + const pluginStorage = storage.get(`${plugin}-messages`) ?? []; + return pluginStorage.filter((val: string) => val === id).length > 0; + }, + [storage] + ); + const removeMessage = useCallback( (plugin: string, id: string) => { const pluginStorage = storage.get(`${plugin}-messages`) ?? []; @@ -48,5 +57,6 @@ export const useMessagesStorage = (): UseMessagesStorage => { addMessage, clearAllMessages, removeMessage, + hasMessage, }; }; diff --git a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx index 5e80953914c970..9aa3b007511a10 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx @@ -89,14 +89,18 @@ interface UseWithSourceState { loading: boolean; } -export const useWithSource = (sourceId = 'default', indexToAdd?: string[] | null) => { +export const useWithSource = ( + sourceId = 'default', + indexToAdd?: string[] | null, + onlyCheckIndexToAdd?: boolean +) => { const [configIndex] = useUiSetting$(DEFAULT_INDEX_KEY); const defaultIndex = useMemo(() => { if (indexToAdd != null && !isEmpty(indexToAdd)) { - return [...configIndex, ...indexToAdd]; + return [...(!onlyCheckIndexToAdd ? configIndex : []), ...indexToAdd]; } return configIndex; - }, [configIndex, indexToAdd]); + }, [configIndex, indexToAdd, onlyCheckIndexToAdd]); const [state, setState] = useState({ browserFields: EMPTY_BROWSER_FIELDS, diff --git a/x-pack/plugins/security_solution/public/management/common/constants.ts b/x-pack/plugins/security_solution/public/management/common/constants.ts index 7456be1d6784d3..0fad1273c72793 100644 --- a/x-pack/plugins/security_solution/public/management/common/constants.ts +++ b/x-pack/plugins/security_solution/public/management/common/constants.ts @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ import { ManagementStoreGlobalNamespace, ManagementSubTab } from '../types'; +import { APP_ID } from '../../../common/constants'; +import { SecurityPageName } from '../../app/types'; // --[ ROUTING ]--------------------------------------------------------------------------- +export const MANAGEMENT_APP_ID = `${APP_ID}:${SecurityPageName.management}`; export const MANAGEMENT_ROUTING_ROOT_PATH = ''; export const MANAGEMENT_ROUTING_ENDPOINTS_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${ManagementSubTab.endpoints})`; export const MANAGEMENT_ROUTING_POLICIES_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${ManagementSubTab.policies})`; diff --git a/x-pack/plugins/security_solution/public/management/components/hooks/use_management_format_url.ts b/x-pack/plugins/security_solution/public/management/components/hooks/use_management_format_url.ts new file mode 100644 index 00000000000000..ea7d929f6044f0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/hooks/use_management_format_url.ts @@ -0,0 +1,18 @@ +/* + * 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 { useKibana } from '../../../common/lib/kibana'; +import { MANAGEMENT_APP_ID } from '../../common/constants'; + +/** + * Returns a full URL to the provided Management page path by using + * kibana's `getUrlForApp()` + * + * @param managementPath + */ +export const useManagementFormatUrl = (managementPath: string) => { + return `${useKibana().services.application.getUrlForApp(MANAGEMENT_APP_ID)}${managementPath}`; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_datasource.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_datasource.tsx index df1591bf78778a..9b2b4b19ce55c5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_datasource.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_datasource.tsx @@ -14,6 +14,7 @@ import { CustomConfigureDatasourceProps, } from '../../../../../../../ingest_manager/public'; import { getPolicyDetailPath } from '../../../../common/routing'; +import { MANAGEMENT_APP_ID } from '../../../../common/constants'; /** * Exports Endpoint-specific datasource configuration instructions @@ -59,7 +60,7 @@ export const ConfigureEndpointDatasource = memo { endpointPackageVersion ? `/endpoint-${endpointPackageVersion}/add-datasource` : '' }`, state: { - onCancelNavigateTo: ['securitySolution:management', { path: getPoliciesPath() }], + onCancelNavigateTo: [MANAGEMENT_APP_ID, { path: getPoliciesPath() }], onCancelUrl: formatUrl(getPoliciesPath()), - onSaveNavigateTo: ['securitySolution:management', { path: getPoliciesPath() }], + onSaveNavigateTo: [MANAGEMENT_APP_ID, { path: getPoliciesPath() }], }, } ); diff --git a/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx b/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx new file mode 100644 index 00000000000000..ee048f0d612129 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx @@ -0,0 +1,65 @@ +/* + * 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, { memo } from 'react'; +import { EuiCallOut, EuiButton, EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { getEndpointListPath } from '../../../management/common/routing'; +import { useNavigateToAppEventHandler } from '../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; +import { useManagementFormatUrl } from '../../../management/components/hooks/use_management_format_url'; +import { MANAGEMENT_APP_ID } from '../../../management/common/constants'; + +export const EndpointNotice = memo<{ onDismiss: () => void }>(({ onDismiss }) => { + const endpointsPath = getEndpointListPath({ name: 'endpointList' }); + const endpointsLink = useManagementFormatUrl(endpointsPath); + const handleGetStartedClick = useNavigateToAppEventHandler(MANAGEMENT_APP_ID, { + path: endpointsPath, + }); + + return ( + + + + + + + } + > + <> +

+ +

+ {/* eslint-disable-next-line @elastic/eui/href-or-on-click*/} + + + + + + + +
+ ); +}); +EndpointNotice.displayName = 'EndpointNotice'; diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx index d6e8fb984ac0ff..bf5e7f0c211b17 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx @@ -11,6 +11,10 @@ import { MemoryRouter } from 'react-router-dom'; import '../../common/mock/match_media'; import { TestProviders } from '../../common/mock'; import { useWithSource } from '../../common/containers/source'; +import { + useMessagesStorage, + UseMessagesStorage, +} from '../../common/containers/local_storage/use_messages_storage'; import { Overview } from './index'; jest.mock('../../common/lib/kibana'); @@ -24,6 +28,17 @@ jest.mock('../../common/components/search_bar', () => ({ jest.mock('../../common/components/query_bar', () => ({ QueryBar: () => null, })); +jest.mock('../../common/containers/local_storage/use_messages_storage'); + +const endpointNoticeMessage = (hasMessageValue: boolean) => { + return { + hasMessage: () => hasMessageValue, + getMessages: () => [], + addMessage: () => undefined, + removeMessage: () => undefined, + clearAllMessages: () => undefined, + }; +}; describe('Overview', () => { describe('rendering', () => { @@ -32,6 +47,9 @@ describe('Overview', () => { indicesExist: false, }); + const mockuseMessagesStorage: jest.Mock = useMessagesStorage as jest.Mock; + mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(false)); + const wrapper = mount( @@ -48,6 +66,10 @@ describe('Overview', () => { indicesExist: true, indexPattern: {}, }); + + const mockuseMessagesStorage: jest.Mock = useMessagesStorage as jest.Mock; + mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(false)); + const wrapper = mount( @@ -57,5 +79,91 @@ describe('Overview', () => { ); expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(false); }); + + test('it DOES render the Endpoint banner when the endpoint index is NOT available AND storage is NOT set', async () => { + (useWithSource as jest.Mock).mockReturnValueOnce({ + indicesExist: true, + indexPattern: {}, + }); + + (useWithSource as jest.Mock).mockReturnValueOnce({ + indicesExist: false, + indexPattern: {}, + }); + + const mockuseMessagesStorage: jest.Mock = useMessagesStorage as jest.Mock; + mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(false)); + + const wrapper = mount( + + + + + + ); + expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(true); + }); + + test('it does NOT render the Endpoint banner when the endpoint index is NOT available but storage is set', async () => { + (useWithSource as jest.Mock).mockReturnValueOnce({ + indicesExist: true, + indexPattern: {}, + }); + + (useWithSource as jest.Mock).mockReturnValueOnce({ + indicesExist: false, + indexPattern: {}, + }); + + const mockuseMessagesStorage: jest.Mock = useMessagesStorage as jest.Mock; + mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(true)); + + const wrapper = mount( + + + + + + ); + expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false); + }); + + test('it does NOT render the Endpoint banner when the endpoint index is available AND storage is set', async () => { + (useWithSource as jest.Mock).mockReturnValue({ + indicesExist: true, + indexPattern: {}, + }); + + const mockuseMessagesStorage: jest.Mock = useMessagesStorage as jest.Mock; + mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(true)); + + const wrapper = mount( + + + + + + ); + expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false); + }); + + test('it does NOT render the Endpoint banner when an index IS available but storage is NOT set', async () => { + (useWithSource as jest.Mock).mockReturnValue({ + indicesExist: true, + indexPattern: {}, + }); + + const mockuseMessagesStorage: jest.Mock = useMessagesStorage as jest.Mock; + mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(false)); + + const wrapper = mount( + + + + + + ); + expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx index 53cb32a16a9de1..b8b8a67024c9fc 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx @@ -5,7 +5,7 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import React from 'react'; +import React, { useState, useMemo } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { StickyContainer } from 'react-sticky'; import { Query, Filter } from 'src/plugins/data/public'; @@ -26,6 +26,9 @@ import { inputsSelectors, State } from '../../common/store'; import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../common/store/inputs/actions'; import { SpyRoute } from '../../common/utils/route/spy_routes'; import { SecurityPageName } from '../../app/types'; +import { EndpointNotice } from '../components/endpoint_notice'; +import { useMessagesStorage } from '../../common/containers/local_storage/use_messages_storage'; +import { ENDPOINT_METADATA_INDEX } from '../../../common/constants'; const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; const NO_FILTERS: Filter[] = []; @@ -39,7 +42,27 @@ const OverviewComponent: React.FC = ({ query = DEFAULT_QUERY, setAbsoluteRangeDatePicker, }) => { + const endpointMetadataIndex = useMemo(() => { + return [ENDPOINT_METADATA_INDEX]; + }, []); + const { indicesExist, indexPattern } = useWithSource(); + const { indicesExist: metadataIndexExists } = useWithSource( + 'default', + endpointMetadataIndex, + true + ); + const { addMessage, hasMessage } = useMessagesStorage(); + const hasDismissEndpointNoticeMessage: boolean = useMemo( + () => hasMessage('management', 'dismissEndpointNotice'), + [hasMessage] + ); + + const [dismissMessage, setDismissMessage] = useState(hasDismissEndpointNoticeMessage); + const dismissEndpointNotice = () => { + setDismissMessage(true); + addMessage('management', 'dismissEndpointNotice'); + }; return ( <> @@ -50,6 +73,12 @@ const OverviewComponent: React.FC = ({ + {!dismissMessage && !metadataIndexExists && ( + <> + + + + )}