From abdc0f17a9c81de4b346c13eecce4167d1140587 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Wed, 17 Jun 2020 10:39:22 -0400 Subject: [PATCH] [Ingest Manager][Endpoint] Add Endpoint Create Policy flow with Ingest (#68955) * Ingest: add data-test-subj prop support to Ingest components * Ingest: Add Context Provider to bridge History from Kibana to Hash router * Ingest: Added support for route state in Create Datasource page * Endpoint: Add Create button to Polices List header * Endpoint: Added support for passing of state from endpoint to ingest on policy * Endpoint: additional functional test cases --- .../ingest_manager/components/header.tsx | 4 +- .../hooks/use_intra_app_state.tsx | 65 ++++ .../applications/ingest_manager/index.tsx | 301 +++++++++--------- .../ingest_manager/layouts/with_header.tsx | 13 +- .../components/layout.tsx | 23 +- .../create_datasource_page/index.tsx | 59 +++- .../step_define_datasource.tsx | 1 + .../step_select_config.tsx | 1 + .../ingest_manager/types/index.ts | 2 + .../types/intra_app_route_state.ts | 27 ++ x-pack/plugins/ingest_manager/public/index.ts | 1 + .../use_navigate_to_app_event_handler.ts | 17 +- .../store/policy_list/services/ingest.ts | 19 ++ .../pages/policy/view/ingest_hooks.ts | 44 +++ .../configure_datasource.tsx | 1 + .../pages/policy/view/policy_list.tsx | 40 +++ .../apps/endpoint/policy_list.ts | 49 ++- .../page_objects/index.ts | 2 + .../ingest_manager_create_datasource_page.ts | 79 +++++ .../page_objects/policy_page.ts | 22 +- .../services/endpoint_policy.ts | 45 +++ 21 files changed, 646 insertions(+), 169 deletions(-) create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_intra_app_state.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/intra_app_route_state.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_hooks.ts create mode 100644 x-pack/test/security_solution_endpoint/page_objects/ingest_manager_create_datasource_page.ts diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/header.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/header.tsx index 9354d94f468018..e0623108e7d39c 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/header.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/header.tsx @@ -36,6 +36,7 @@ export interface HeaderProps { rightColumn?: JSX.Element; rightColumnGrow?: EuiFlexItemProps['grow']; tabs?: EuiTabProps[]; + 'data-test-subj'?: string; } const HeaderColumns: React.FC> = memo( @@ -53,8 +54,9 @@ export const Header: React.FC = ({ rightColumnGrow, tabs, maxWidth, + 'data-test-subj': dataTestSubj, }) => ( - + { + forRoute: string; + routeState?: S; +} + +const IntraAppStateContext = React.createContext({ forRoute: '' }); +const wasHandled = new WeakSet(); + +/** + * Provides a bridget between Kibana's ScopedHistory instance (normally used with BrowserRouter) + * and the Hash router used within the app in order to enable state to be used between kibana + * apps + */ +export const IntraAppStateProvider = memo<{ + kibanaScopedHistory: AppMountParameters['history']; + children: React.ReactNode; +}>(({ kibanaScopedHistory, children }) => { + const internalAppToAppState = useMemo(() => { + return { + forRoute: kibanaScopedHistory.location.hash.substr(1), + routeState: kibanaScopedHistory.location.state as AnyIntraAppRouteState, + }; + }, [kibanaScopedHistory.location.hash, kibanaScopedHistory.location.state]); + return ( + + {children} + + ); +}); + +/** + * Retrieve UI Route state from the React Router History for the current URL location. + * This state can be used by other Kibana Apps to influence certain behaviours in Ingest, for example, + * redirecting back to an given Application after a craete action. + */ +export function useIntraAppState(): + | IntraAppState['routeState'] + | undefined { + const location = useLocation(); + const intraAppState = useContext(IntraAppStateContext); + if (!intraAppState) { + throw new Error('Hook called outside of IntraAppStateContext'); + } + return useMemo(() => { + // Due to the use of HashRouter in Ingest, we only want state to be returned + // once so that it does not impact navigation to the page from within the + // ingest app. side affect is that the browser back button would not work + // consistently either. + if (location.pathname === intraAppState.forRoute && !wasHandled.has(intraAppState)) { + wasHandled.add(intraAppState); + return intraAppState.routeState as S; + } + }, [intraAppState, location.pathname]); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx index ed5a75ce6c991e..623df428b7dd92 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx @@ -3,7 +3,7 @@ * 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, { useEffect, useState } from 'react'; +import React, { memo, useEffect, useState } from 'react'; import ReactDOM from 'react-dom'; import { useObservable } from 'react-use'; import { HashRouter as Router, Redirect, Switch, Route, RouteProps } from 'react-router-dom'; @@ -28,6 +28,7 @@ import { useCore, sendSetup, sendGetPermissionsCheck } from './hooks'; import { FleetStatusProvider } from './hooks/use_fleet_status'; import './index.scss'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { IntraAppStateProvider } from './hooks/use_intra_app_state'; export interface ProtectedRouteProps extends RouteProps { isAllowed?: boolean; @@ -56,163 +57,170 @@ const ErrorLayout = ({ children }: { children: JSX.Element }) => ( ); -const IngestManagerRoutes = ({ ...rest }) => { - const { epm, fleet } = useConfig(); - const { notifications } = useCore(); +const IngestManagerRoutes = memo<{ history: AppMountParameters['history']; basepath: string }>( + ({ history, ...rest }) => { + const { epm, fleet } = useConfig(); + const { notifications } = useCore(); - const [isPermissionsLoading, setIsPermissionsLoading] = useState(false); - const [permissionsError, setPermissionsError] = useState(); - const [isInitialized, setIsInitialized] = useState(false); - const [initializationError, setInitializationError] = useState(null); + const [isPermissionsLoading, setIsPermissionsLoading] = useState(false); + const [permissionsError, setPermissionsError] = useState(); + const [isInitialized, setIsInitialized] = useState(false); + const [initializationError, setInitializationError] = useState(null); - useEffect(() => { - (async () => { - setIsPermissionsLoading(false); - setPermissionsError(undefined); - setIsInitialized(false); - setInitializationError(null); - try { - setIsPermissionsLoading(true); - const permissionsResponse = await sendGetPermissionsCheck(); + useEffect(() => { + (async () => { setIsPermissionsLoading(false); - if (permissionsResponse.data?.success) { - try { - const setupResponse = await sendSetup(); - if (setupResponse.error) { - setInitializationError(setupResponse.error); + setPermissionsError(undefined); + setIsInitialized(false); + setInitializationError(null); + try { + setIsPermissionsLoading(true); + const permissionsResponse = await sendGetPermissionsCheck(); + setIsPermissionsLoading(false); + if (permissionsResponse.data?.success) { + try { + const setupResponse = await sendSetup(); + if (setupResponse.error) { + setInitializationError(setupResponse.error); + } + } catch (err) { + setInitializationError(err); } - } catch (err) { - setInitializationError(err); + setIsInitialized(true); + } else { + setPermissionsError(permissionsResponse.data?.error || 'REQUEST_ERROR'); } - setIsInitialized(true); - } else { - setPermissionsError(permissionsResponse.data?.error || 'REQUEST_ERROR'); + } catch (err) { + setPermissionsError('REQUEST_ERROR'); } - } catch (err) { - setPermissionsError('REQUEST_ERROR'); - } - })(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - if (isPermissionsLoading || permissionsError) { - return ( - - {isPermissionsLoading ? ( - - ) : permissionsError === 'REQUEST_ERROR' ? ( - - } - error={i18n.translate('xpack.ingestManager.permissionsRequestErrorMessageDescription', { - defaultMessage: 'There was a problem checking Ingest Manager permissions', - })} - /> - ) : ( - - + {isPermissionsLoading ? ( + + ) : permissionsError === 'REQUEST_ERROR' ? ( + - {permissionsError === 'MISSING_SUPERUSER_ROLE' ? ( - - ) : ( - - )} - + } - body={ -

- {permissionsError === 'MISSING_SUPERUSER_ROLE' ? ( - superuser }} - /> - ) : ( - - )} -

+ error={i18n.translate( + 'xpack.ingestManager.permissionsRequestErrorMessageDescription', + { + defaultMessage: 'There was a problem checking Ingest Manager permissions', + } + )} + /> + ) : ( + + + {permissionsError === 'MISSING_SUPERUSER_ROLE' ? ( + + ) : ( + + )} + + } + body={ +

+ {permissionsError === 'MISSING_SUPERUSER_ROLE' ? ( + superuser }} + /> + ) : ( + + )} +

+ } + /> +
+ )} +
+ ); + } + + if (!isInitialized || initializationError) { + return ( + + {initializationError ? ( + } + error={initializationError} /> - - )} - - ); - } + ) : ( + + )} + + ); + } - if (!isInitialized || initializationError) { return ( - - {initializationError ? ( - - } - error={initializationError} - /> - ) : ( - - )} - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); } - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; +); const IngestManagerApp = ({ basepath, @@ -220,12 +228,14 @@ const IngestManagerApp = ({ setupDeps, startDeps, config, + history, }: { basepath: string; coreStart: CoreStart; setupDeps: IngestManagerSetupDeps; startDeps: IngestManagerStartDeps; config: IngestManagerConfigType; + history: AppMountParameters['history']; }) => { const isDarkMode = useObservable(coreStart.uiSettings.get$('theme:darkMode')); return ( @@ -234,7 +244,7 @@ const IngestManagerApp = ({ - + @@ -245,7 +255,7 @@ const IngestManagerApp = ({ export function renderApp( coreStart: CoreStart, - { element, appBasePath }: AppMountParameters, + { element, appBasePath, history }: AppMountParameters, setupDeps: IngestManagerSetupDeps, startDeps: IngestManagerStartDeps, config: IngestManagerConfigType @@ -258,6 +268,7 @@ export function renderApp( setupDeps={setupDeps} startDeps={startDeps} config={config} + history={history} />, element ); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/with_header.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/with_header.tsx index ac7f85bf5f594e..58ca989850bf1f 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/with_header.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/with_header.tsx @@ -15,6 +15,7 @@ const Page = styled(EuiPage)` interface Props extends HeaderProps { restrictWidth?: number; restrictHeaderWidth?: number; + 'data-test-subj'?: string; children?: React.ReactNode; } @@ -22,11 +23,19 @@ export const WithHeaderLayout: React.FC = ({ restrictWidth, restrictHeaderWidth, children, + 'data-test-subj': dataTestSubj, ...rest }) => ( -
- +
+ {children} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx index ccd2bc75fe2230..7939feed801430 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx @@ -23,13 +23,31 @@ import { CreateDatasourceFrom } from '../types'; export const CreateDatasourcePageLayout: React.FunctionComponent<{ from: CreateDatasourceFrom; cancelUrl: string; + cancelOnClick?: React.ReactEventHandler; agentConfig?: AgentConfig; packageInfo?: PackageInfo; -}> = ({ from, cancelUrl, agentConfig, packageInfo, children }) => { + 'data-test-subj'?: string; +}> = ({ + from, + cancelUrl, + cancelOnClick, + agentConfig, + packageInfo, + children, + 'data-test-subj': dataTestSubj, +}) => { const leftColumn = ( - + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + {children} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/index.tsx index 577f08cdc33138..876383770aa718 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/index.tsx @@ -3,7 +3,7 @@ * 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, { useState, useEffect, useMemo, useCallback } from 'react'; +import React, { useState, useEffect, useMemo, useCallback, ReactEventHandler } from 'react'; import { useRouteMatch, useHistory } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -17,7 +17,12 @@ import { EuiSpacer, } from '@elastic/eui'; import { EuiStepProps } from '@elastic/eui/src/components/steps/step'; -import { AgentConfig, PackageInfo, NewDatasource } from '../../../types'; +import { + AgentConfig, + PackageInfo, + NewDatasource, + CreateDatasourceRouteState, +} from '../../../types'; import { useLink, useBreadcrumbs, @@ -34,12 +39,14 @@ import { StepSelectPackage } from './step_select_package'; import { StepSelectConfig } from './step_select_config'; import { StepConfigureDatasource } from './step_configure_datasource'; import { StepDefineDatasource } from './step_define_datasource'; +import { useIntraAppState } from '../../../hooks/use_intra_app_state'; export const CreateDatasourcePage: React.FunctionComponent = () => { const { notifications, chrome: { getIsNavDrawerLocked$ }, uiSettings, + application: { navigateToApp }, } = useCore(); const { fleet: { enabled: isFleetEnabled }, @@ -49,6 +56,7 @@ export const CreateDatasourcePage: React.FunctionComponent = () => { } = useRouteMatch(); const { getHref, getPath } = useLink(); const history = useHistory(); + const routeState = useIntraAppState(); const from: CreateDatasourceFrom = configId ? 'config' : 'package'; const [isNavDrawerLocked, setIsNavDrawerLocked] = useState(false); @@ -171,10 +179,24 @@ export const CreateDatasourcePage: React.FunctionComponent = () => { }; // Cancel path - const cancelUrl = - from === 'config' - ? getHref('configuration_details', { configId: agentConfig?.id || configId }) + const cancelUrl = useMemo(() => { + if (routeState && routeState.onCancelUrl) { + return routeState.onCancelUrl; + } + return from === 'config' + ? getHref('configuration_details', { configId: agentConfigId || configId }) : getHref('integration_details', { pkgkey }); + }, [agentConfigId, configId, from, getHref, pkgkey, routeState]); + + const cancelClickHandler: ReactEventHandler = useCallback( + (ev) => { + if (routeState && routeState.onCancelNavigateTo) { + ev.preventDefault(); + navigateToApp(...routeState.onCancelNavigateTo); + } + }, + [routeState, navigateToApp] + ); // Save datasource const saveDatasource = async () => { @@ -193,9 +215,18 @@ export const CreateDatasourcePage: React.FunctionComponent = () => { setFormState('CONFIRM'); return; } - const { error } = await saveDatasource(); + const { error, data } = await saveDatasource(); if (!error) { - history.push(getPath('configuration_details', { configId: agentConfig?.id || configId })); + if (routeState && routeState.onSaveNavigateTo) { + navigateToApp( + ...(typeof routeState.onSaveNavigateTo === 'function' + ? routeState.onSaveNavigateTo(data!.item) + : routeState.onSaveNavigateTo) + ); + } else { + history.push(getPath('configuration_details', { configId: agentConfig?.id || configId })); + } + notifications.toasts.addSuccess({ title: i18n.translate('xpack.ingestManager.createDatasource.addedNotificationTitle', { defaultMessage: `Successfully added '{datasourceName}'`, @@ -212,6 +243,7 @@ export const CreateDatasourcePage: React.FunctionComponent = () => { }, }) : undefined, + 'data-test-subj': 'datasourceCreateSuccessToast', }); } else { notifications.toasts.addError(error, { @@ -224,6 +256,7 @@ export const CreateDatasourcePage: React.FunctionComponent = () => { const layoutProps = { from, cancelUrl, + cancelOnClick: cancelClickHandler, agentConfig, packageInfo, }; @@ -287,6 +320,7 @@ export const CreateDatasourcePage: React.FunctionComponent = () => { defaultMessage: 'Select the data you want to collect', }), status: !packageInfo || !agentConfig ? 'disabled' : undefined, + 'data-test-subj': 'dataCollectionSetupStep', children: agentConfig && packageInfo ? ( { ]; return ( - + {formState === 'CONFIRM' && agentConfig && ( { > - + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + { iconType="save" color="primary" fill + data-test-subj="createDatasourceSaveButton" > diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_config.tsx index 22cb219f911f6a..5f556a46e518df 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_config.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_config.tsx @@ -113,6 +113,7 @@ export const StepSelectConfig: React.FunctionComponent<{ label: name, key: id, checked: selectedConfigId === id ? 'on' : undefined, + 'data-test-subj': 'agentConfigItem', }; })} renderOption={(option) => ( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts index 412bf412d1ef5a..1a4c6a8a86f6ed 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts @@ -99,3 +99,5 @@ export { InstallationStatus, Installable, } from '../../../../common'; + +export * from './intra_app_route_state'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/intra_app_route_state.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/intra_app_route_state.ts new file mode 100644 index 00000000000000..6e85d12f718910 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/intra_app_route_state.ts @@ -0,0 +1,27 @@ +/* + * 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 { ApplicationStart } from 'kibana/public'; +import { Datasource } from '../../../../common/types/models'; + +/** + * Supported routing state for the create datasource page routes + */ +export interface CreateDatasourceRouteState { + /** On a successful save of the datasource, use navigate to the given app */ + onSaveNavigateTo?: + | Parameters + | ((newDatasource: Datasource) => Parameters); + /** On cancel, navigate to the given app */ + onCancelNavigateTo?: Parameters; + /** Url to be used on cancel links */ + onCancelUrl?: string; +} + +/** + * All possible Route states. + */ +export type AnyIntraAppRouteState = CreateDatasourceRouteState; diff --git a/x-pack/plugins/ingest_manager/public/index.ts b/x-pack/plugins/ingest_manager/public/index.ts index e26f310b6d9c6c..9f4893ac6e499f 100644 --- a/x-pack/plugins/ingest_manager/public/index.ts +++ b/x-pack/plugins/ingest_manager/public/index.ts @@ -19,3 +19,4 @@ export { } from './applications/ingest_manager/sections/agent_config/create_datasource_page/components/custom_configure_datasource'; export { NewDatasource } from './applications/ingest_manager/types'; +export * from './applications/ingest_manager/types/intra_app_route_state'; diff --git a/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_navigate_to_app_event_handler.ts b/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_navigate_to_app_event_handler.ts index 759d72419e42eb..cb4de29802e541 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_navigate_to_app_event_handler.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_navigate_to_app_event_handler.ts @@ -5,10 +5,13 @@ */ import { MouseEventHandler, useCallback } from 'react'; -import { ApplicationStart } from 'kibana/public'; -import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { ApplicationStart, NavigateToAppOptions } from 'kibana/public'; +import { useKibana } from '../../lib/kibana'; -type NavigateToAppHandlerProps = Parameters; +type NavigateToAppHandlerOptions = NavigateToAppOptions & { + state?: S; + onClick?: EventHandlerCallback; +}; type EventHandlerCallback = MouseEventHandler; /** @@ -25,14 +28,12 @@ type EventHandlerCallback = MouseEventHandlerSee configs */ -export const useNavigateToAppEventHandler = ( +export const useNavigateToAppEventHandler = ( /** the app id - normally the value of the `id` in that plugin's `kibana.json` */ - appId: NavigateToAppHandlerProps[0], + appId: Parameters[0], /** Options, some of which are passed along to the app route */ - options?: NavigateToAppHandlerProps[1] & { - onClick?: EventHandlerCallback; - } + options?: NavigateToAppHandlerOptions ): EventHandlerCallback => { const { services } = useKibana(); const { path, state, onClick } = options || {}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.ts index cece3f1b4c8f2b..66e98aa51601e7 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.ts @@ -11,6 +11,7 @@ import { DeleteDatasourcesResponse, DeleteDatasourcesRequest, DATASOURCE_SAVED_OBJECT_TYPE, + GetPackagesResponse, } from '../../../../../../../../ingest_manager/common'; import { GetPolicyListResponse, GetPolicyResponse, UpdatePolicyResponse } from '../../../types'; import { NewPolicyData } from '../../../../../../../common/endpoint/types'; @@ -19,6 +20,7 @@ const INGEST_API_ROOT = `/api/ingest_manager`; export const INGEST_API_DATASOURCES = `${INGEST_API_ROOT}/datasources`; const INGEST_API_FLEET = `${INGEST_API_ROOT}/fleet`; const INGEST_API_FLEET_AGENT_STATUS = `${INGEST_API_FLEET}/agent-status`; +const INGEST_API_EPM_PACKAGES = `${INGEST_API_ROOT}/epm/packages`; const INGEST_API_DELETE_DATASOURCE = `${INGEST_API_DATASOURCES}/delete`; /** @@ -113,3 +115,20 @@ export const sendGetFleetAgentStatusForConfig = ( }, }); }; + +/** + * Get Endpoint Security Package information + */ +export const sendGetEndpointSecurityPackage = async ( + http: HttpStart +): Promise => { + const options = { query: { category: 'security' } }; + const securityPackages = await http.get(INGEST_API_EPM_PACKAGES, options); + const endpointPackageInfo = securityPackages.response.find( + (epmPackage) => epmPackage.name === 'endpoint' + ); + if (!endpointPackageInfo) { + throw new Error('Endpoint package was not found.'); + } + return endpointPackageInfo; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_hooks.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_hooks.ts new file mode 100644 index 00000000000000..75e1556ff0bb08 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_hooks.ts @@ -0,0 +1,44 @@ +/* + * 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 { useEffect, useState } from 'react'; +import { Immutable } from '../../../../../common/endpoint/types'; +import { GetPackagesResponse } from '../../../../../../ingest_manager/common/types/rest_spec'; +import { sendGetEndpointSecurityPackage } from '../store/policy_list/services/ingest'; +import { useKibana } from '../../../../common/lib/kibana'; + +type UseEndpointPackageInfo = [ + /** The Package Info. will be undefined while it is being fetched */ + Immutable | undefined, + /** Boolean indicating if fetching is underway */ + boolean, + /** Any error encountered during fetch */ + Error | undefined +]; + +/** + * Hook that fetches the endpoint package info + * + * @example + * const [packageInfo, isFetching, fetchError] = useEndpointPackageInfo(); + */ +export const useEndpointPackageInfo = (): UseEndpointPackageInfo => { + const { + services: { http }, + } = useKibana(); + const [endpointPackage, setEndpointPackage] = useState(); + const [isFetching, setIsFetching] = useState(true); + const [error, setError] = useState(); + + useEffect(() => { + sendGetEndpointSecurityPackage(http) + .then((packageInfo) => setEndpointPackage(packageInfo)) + .catch((apiError) => setError(apiError)) + .finally(() => setIsFetching(false)); + }, [http]); + + return [endpointPackage, isFetching, error]; +}; 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 e80856f35081bf..5e721cadfa8234 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 @@ -32,6 +32,7 @@ export const ConfigureEndpointDatasource = memo

diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx index 24254530f75dbb..090a16a7636645 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx @@ -21,6 +21,7 @@ import { EuiConfirmModal, EuiCallOut, EuiSpacer, + EuiButton, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -40,6 +41,9 @@ import { ManagementPageView } from '../../../components/management_page_view'; import { SpyRoute } from '../../../../common/utils/route/spy_routes'; import { getManagementUrl } from '../../../common/routing'; import { FormattedDateAndTime } from '../../../../common/components/endpoint/formatted_date_time'; +import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; +import { CreateDatasourceRouteState } from '../../../../../../ingest_manager/public'; +import { useEndpointPackageInfo } from './ingest_hooks'; interface TableChangeCallbackArguments { page: { index: number; size: number }; @@ -121,6 +125,7 @@ export const PolicyList = React.memo(() => { const [policyIdToDelete, setPolicyIdToDelete] = useState(''); const dispatch = useDispatch<(action: PolicyListAction) => void>(); + const [packageInfo, isFetchingPackageInfo] = useEndpointPackageInfo(); const { selectPolicyItems: policyItems, selectPageIndex: pageIndex, @@ -133,6 +138,28 @@ export const PolicyList = React.memo(() => { selectAgentStatusSummary: agentStatusSummary, } = usePolicyListSelector(selector); + const handleCreatePolicyClick = useNavigateToAppEventHandler( + 'ingestManager', + { + // We redirect to Ingest's Integaration page if we can't get the package version, and + // to the Integration Endpoint Package Add Datasource if we have package information. + // Also, + // We pass along soem state information so that the Ingest page can change the behaviour + // of the cancel and submit buttons and redirect the user back to endpoint policy + path: `#/integrations${packageInfo ? `/endpoint-${packageInfo.version}/add-datasource` : ''}`, + state: { + onCancelNavigateTo: [ + 'securitySolution', + { path: getManagementUrl({ name: 'policyList' }) }, + ], + onCancelUrl: services.application?.getUrlForApp('securitySolution', { + path: getManagementUrl({ name: 'policyList' }), + }), + onSaveNavigateTo: ['securitySolution', { path: getManagementUrl({ name: 'policyList' }) }], + }, + } + ); + useEffect(() => { if (apiError) { notifications.toasts.danger({ @@ -369,6 +396,19 @@ export const PolicyList = React.memo(() => { headerLeft={i18n.translate('xpack.securitySolution.endpoint.policyList.viewTitle', { defaultMessage: 'Policies', })} + headerRight={ + + + + } bodyHeader={ { + const createButtonTitle = await testSubjects.getVisibleText('headerCreateNewPolicyButton'); + expect(createButtonTitle).to.equal('Create new policy'); + }); it('shows policy count total', async () => { const policyTotal = await testSubjects.getVisibleText('policyTotalCount'); expect(policyTotal).to.equal('0 Policies'); @@ -101,5 +111,42 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(policyTotal).to.equal('0 Policies'); }); }); + + describe('and user clicks on page header create button', () => { + beforeEach(async () => { + await pageObjects.policy.navigateToPolicyList(); + await (await pageObjects.policy.findHeaderCreateNewButton()).click(); + }); + + it('should redirect to ingest management integrations add datasource', async () => { + await pageObjects.ingestManagerCreateDatasource.ensureOnCreatePageOrFail(); + }); + + it('should redirect user back to Policy List if Cancel button is clicked', async () => { + await (await pageObjects.ingestManagerCreateDatasource.findCancelButton()).click(); + await pageObjects.policy.ensureIsOnPolicyPage(); + }); + + it('should redirect user back to Policy List if Back link is clicked', async () => { + await (await pageObjects.ingestManagerCreateDatasource.findBackLink()).click(); + await pageObjects.policy.ensureIsOnPolicyPage(); + }); + + it('should display custom endpoint configuration message', async () => { + await pageObjects.ingestManagerCreateDatasource.selectAgentConfig(); + const endpointConfig = await pageObjects.policy.findDatasourceEndpointCustomConfiguration(); + expect(endpointConfig).not.to.be(undefined); + }); + + it('should redirect user back to Policy List after a successful save', async () => { + const newPolicyName = `endpoint policy ${Date.now()}`; + await pageObjects.ingestManagerCreateDatasource.selectAgentConfig(); + await pageObjects.ingestManagerCreateDatasource.setDatasourceName(newPolicyName); + await (await pageObjects.ingestManagerCreateDatasource.findDSaveButton()).click(); + await pageObjects.ingestManagerCreateDatasource.waitForSaveSuccessNotification(); + await pageObjects.policy.ensureIsOnPolicyPage(); + await policyTestResources.deletePolicyByName(newPolicyName); + }); + }); }); } diff --git a/x-pack/test/security_solution_endpoint/page_objects/index.ts b/x-pack/test/security_solution_endpoint/page_objects/index.ts index 5b550bea5b55dc..96e2a47e7803e7 100644 --- a/x-pack/test/security_solution_endpoint/page_objects/index.ts +++ b/x-pack/test/security_solution_endpoint/page_objects/index.ts @@ -9,6 +9,7 @@ import { EndpointPageProvider } from './endpoint_page'; import { EndpointAlertsPageProvider } from './endpoint_alerts_page'; import { EndpointPolicyPageProvider } from './policy_page'; import { EndpointPageUtils } from './page_utils'; +import { IngestManagerCreateDatasource } from './ingest_manager_create_datasource_page'; export const pageObjects = { ...xpackFunctionalPageObjects, @@ -16,4 +17,5 @@ export const pageObjects = { policy: EndpointPolicyPageProvider, endpointPageUtils: EndpointPageUtils, endpointAlerts: EndpointAlertsPageProvider, + ingestManagerCreateDatasource: IngestManagerCreateDatasource, }; diff --git a/x-pack/test/security_solution_endpoint/page_objects/ingest_manager_create_datasource_page.ts b/x-pack/test/security_solution_endpoint/page_objects/ingest_manager_create_datasource_page.ts new file mode 100644 index 00000000000000..f50cde6285be72 --- /dev/null +++ b/x-pack/test/security_solution_endpoint/page_objects/ingest_manager_create_datasource_page.ts @@ -0,0 +1,79 @@ +/* + * 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 { FtrProviderContext } from '../ftr_provider_context'; + +export function IngestManagerCreateDatasource({ getService }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const find = getService('find'); + + return { + /** + * Validates that the page shown is the Datasource Craete Page + */ + async ensureOnCreatePageOrFail() { + await testSubjects.existOrFail('createDataSource_header'); + }, + + /** + * Finds and returns the Cancel button on the sticky bottom bar + */ + async findCancelButton() { + return await testSubjects.find('createDatasourceCancelButton'); + }, + + /** + * Finds and returns the Cancel back link at the top of the create page + */ + async findBackLink() { + return await testSubjects.find('createDataSource_cancelBackLink'); + }, + + /** + * Finds and returns the save button on the sticky bottom bar + */ + async findDSaveButton() { + return await testSubjects.find('createDatasourceSaveButton'); + }, + + /** + * Selects an agent configuration on the form + * @param name + * Visual name of the configuration. if one is not provided, the first agent + * configuration on the list will be chosen + */ + async selectAgentConfig(name?: string) { + // if we have a name, then find the button with that `title` set. + if (name) { + await ( + await find.byCssSelector(`[data-test-subj="agentConfigItem"][title="${name}"]`) + ).click(); + } + // Else, just select the first agent configuration that is present + else { + await (await testSubjects.find('agentConfigItem')).click(); + } + }, + + /** + * Set the name of the datasource on the input field + * @param name + */ + async setDatasourceName(name: string) { + // Because of the bottom sticky bar, we need to scroll section 2 into view + // so that `setValue()` enters the data on the input field. + await testSubjects.scrollIntoView('dataCollectionSetupStep'); + await testSubjects.setValue('datasourceNameInput', name); + }, + + /** + * Waits for the save Notification toast to be visible + */ + async waitForSaveSuccessNotification() { + await testSubjects.existOrFail('datasourceCreateSuccessToast'); + }, + }; +} diff --git a/x-pack/test/security_solution_endpoint/page_objects/policy_page.ts b/x-pack/test/security_solution_endpoint/page_objects/policy_page.ts index eb481cdfc99c44..a2b0f9a671039f 100644 --- a/x-pack/test/security_solution_endpoint/page_objects/policy_page.ts +++ b/x-pack/test/security_solution_endpoint/page_objects/policy_page.ts @@ -42,7 +42,7 @@ export function EndpointPolicyPageProvider({ getService, getPageObjects }: FtrPr * ensures that the Policy Page is the currently display view */ async ensureIsOnPolicyPage() { - await testSubjects.existOrFail('policyTable'); + await testSubjects.existOrFail('policyListPage'); }, /** @@ -81,5 +81,25 @@ export function EndpointPolicyPageProvider({ getService, getPageObjects }: FtrPr await testSubjects.existOrFail('policyDetailsConfirmModal'); await pageObjects.common.clickConfirmOnModal(); }, + + /** + * Finds and returns the Create New policy Policy button displayed on the List page + */ + async findHeaderCreateNewButton() { + // The Create button is initially disabled because we need to first make a call to Ingest + // to retrieve the package version, so that the redirect works as expected. So, we wait + // for that to occur here a well. + await testSubjects.waitForEnabled('headerCreateNewPolicyButton'); + return await testSubjects.find('headerCreateNewPolicyButton'); + }, + + /** + * Used when looking a the Ingest create/edit datasource pages. Finds the endpoint + * custom configuaration component + * @param onEditPage + */ + async findDatasourceEndpointCustomConfiguration(onEditPage: boolean = false) { + return await testSubjects.find(`endpointDatasourceConfig_${onEditPage ? 'edit' : 'create'}`); + }, }; } diff --git a/x-pack/test/security_solution_endpoint/services/endpoint_policy.ts b/x-pack/test/security_solution_endpoint/services/endpoint_policy.ts index cfba9e803be7d5..fbed7dcc663ecd 100644 --- a/x-pack/test/security_solution_endpoint/services/endpoint_policy.ts +++ b/x-pack/test/security_solution_endpoint/services/endpoint_policy.ts @@ -10,8 +10,10 @@ import { CreateAgentConfigResponse, CreateDatasourceRequest, CreateDatasourceResponse, + DATASOURCE_SAVED_OBJECT_TYPE, DeleteAgentConfigRequest, DeleteDatasourcesRequest, + GetDatasourcesResponse, GetFullAgentConfigResponse, GetPackagesResponse, } from '../../../plugins/ingest_manager/common'; @@ -223,5 +225,48 @@ export function EndpointPolicyTestResourcesProvider({ getService }: FtrProviderC }, }; }, + + /** + * Deletes a policy (Datasource) by using the policy name + * @param name + */ + async deletePolicyByName(name: string) { + let datasourceList: GetDatasourcesResponse['items']; + try { + const { body: datasourcesResponse }: { body: GetDatasourcesResponse } = await supertest + .get(INGEST_API_DATASOURCES) + .set('kbn-xsrf', 'xxx') + .query({ kuery: `${DATASOURCE_SAVED_OBJECT_TYPE}.name: ${name}` }) + .send() + .expect(200); + datasourceList = datasourcesResponse.items; + } catch (error) { + return logSupertestApiErrorAndThrow( + `Unable to get list of datasources with name=${name}`, + error + ); + } + + if (datasourceList.length === 0) { + throw new Error(`Policy named '${name}' was not found!`); + } + + if (datasourceList.length > 1) { + throw new Error(`Found ${datasourceList.length} Policies - was expecting only one!`); + } + + try { + const deleteDatasourceData: DeleteDatasourcesRequest['body'] = { + datasourceIds: [datasourceList[0].id], + }; + await supertest + .post(INGEST_API_DATASOURCES_DELETE) + .set('kbn-xsrf', 'xxx') + .send(deleteDatasourceData) + .expect(200); + } catch (error) { + logSupertestApiErrorAndThrow('Unable to delete Datasource via Ingest!', error); + } + }, }; }