Skip to content

Commit

Permalink
[ENDPOINT][SIEM] Display dismissible Endpoint notice on Overview page…
Browse files Browse the repository at this point in the history
… if no endpoints are deployed (#70122) (#70511)

Co-authored-by: Paul Tavares <56442535+paul-tavares@users.noreply.github.com>
  • Loading branch information
kevinlog and paul-tavares authored Jul 2, 2020
1 parent ae96a88 commit fcd0d20
Show file tree
Hide file tree
Showing 11 changed files with 262 additions and 7 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 @@ -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`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@ describe('useLocalStorage', () => {
});
});

it('should return presence of a message', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseMessagesStorage>(() =>
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<string, UseMessagesStorage>(() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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`) ?? [];
Expand All @@ -48,5 +57,6 @@ export const useMessagesStorage = (): UseMessagesStorage => {
addMessage,
clearAllMessages,
removeMessage,
hasMessage,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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$<string[]>(DEFAULT_INDEX_KEY);
const defaultIndex = useMemo<string[]>(() => {
if (indexToAdd != null && !isEmpty(indexToAdd)) {
return [...configIndex, ...indexToAdd];
return [...(!onlyCheckIndexToAdd ? configIndex : []), ...indexToAdd];
}
return configIndex;
}, [configIndex, indexToAdd]);
}, [configIndex, indexToAdd, onlyCheckIndexToAdd]);

const [state, setState] = useState<UseWithSourceState>({
browserFields: EMPTY_BROWSER_FIELDS,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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})`;
Expand Down
Original file line number Diff line number Diff line change
@@ -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}`;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -59,7 +60,7 @@ export const ConfigureEndpointDatasource = memo<CustomConfigureDatasourceContent
<LinkToApp
data-test-subj="editLinkToPolicyDetails"
asButton={true}
appId="securitySolution:management"
appId={MANAGEMENT_APP_ID}
className="editLinkToPolicyDetails"
appPath={policyUrl}
// Cannot use formalUrl here since the code is called in Ingest, which does not use redux
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { useFormatUrl } from '../../../../common/components/link_to';
import { getPolicyDetailPath, getPoliciesPath } from '../../../common/routing';
import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/use_navigate_to_app_event_handler';
import { CreateDatasourceRouteState } from '../../../../../../ingest_manager/public';
import { MANAGEMENT_APP_ID } from '../../../common/constants';

interface TableChangeCallbackArguments {
page: { index: number; size: number };
Expand Down Expand Up @@ -153,9 +154,9 @@ export const PolicyList = React.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() }],
},
}
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<EuiCallOut
data-test-subj="endpoint-prompt-banner"
iconType="cheer"
title={
<>
<b>
<FormattedMessage
id="xpack.securitySolution.overview.endpointNotice.introducing"
defaultMessage="Introducing: "
/>
</b>
<FormattedMessage
id="xpack.securitySolution.overview.endpointNotice.title"
defaultMessage="Elastic Endpoint Security Beta"
/>
</>
}
>
<>
<p>
<FormattedMessage
id="xpack.securitySolution.overview.endpointNotice.message"
defaultMessage="Elastic Endpoint Security gives you the power to keep your endpoints safe from attack, as well as unparalleled visibility into any threat in your environment."
/>
</p>
{/* eslint-disable-next-line @elastic/eui/href-or-on-click*/}
<EuiButton onClick={handleGetStartedClick} href={endpointsLink}>
<FormattedMessage
id="xpack.securitySolution.overview.endpointNotice.tryButton"
defaultMessage="Try Elastic Endpoint Security Beta"
/>
</EuiButton>
<EuiButtonEmpty onClick={onDismiss}>
<FormattedMessage
id="xpack.securitySolution.overview.endpointNotice.dismiss"
defaultMessage="Dismiss message"
/>
</EuiButtonEmpty>
</>
</EuiCallOut>
);
});
EndpointNotice.displayName = 'EndpointNotice';
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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', () => {
Expand All @@ -32,6 +47,9 @@ describe('Overview', () => {
indicesExist: false,
});

const mockuseMessagesStorage: jest.Mock = useMessagesStorage as jest.Mock<UseMessagesStorage>;
mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(false));

const wrapper = mount(
<TestProviders>
<MemoryRouter>
Expand All @@ -48,6 +66,10 @@ describe('Overview', () => {
indicesExist: true,
indexPattern: {},
});

const mockuseMessagesStorage: jest.Mock = useMessagesStorage as jest.Mock<UseMessagesStorage>;
mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(false));

const wrapper = mount(
<TestProviders>
<MemoryRouter>
Expand All @@ -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<UseMessagesStorage>;
mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(false));

const wrapper = mount(
<TestProviders>
<MemoryRouter>
<Overview />
</MemoryRouter>
</TestProviders>
);
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<UseMessagesStorage>;
mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(true));

const wrapper = mount(
<TestProviders>
<MemoryRouter>
<Overview />
</MemoryRouter>
</TestProviders>
);
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<UseMessagesStorage>;
mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(true));

const wrapper = mount(
<TestProviders>
<MemoryRouter>
<Overview />
</MemoryRouter>
</TestProviders>
);
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<UseMessagesStorage>;
mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(false));

const wrapper = mount(
<TestProviders>
<MemoryRouter>
<Overview />
</MemoryRouter>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false);
});
});
});
Loading

0 comments on commit fcd0d20

Please sign in to comment.