From f2c0e69dbc844aa5822fdb3541b8dd5ef03b1ca9 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Fri, 17 Apr 2020 10:58:01 -0400 Subject: [PATCH] [Endpoint] Host Details Policy Response Panel (#63518) (#63759) * Added link to Policy status that updates URL and show details panel * Custom Styled Flyout Panel sub-header component to display sub-headers * Move Middleware spy utils under `store/` for re-use * Changed `appStoreFactory()` to accept optional `additionalMiddleware` prop * `waitForAction` middleware test utility now return Action on Promise resolve * Updated PageView component to remove bottom margin --- .../endpoint/mocks/app_context_render.tsx | 10 +- .../endpoint/store/hosts/selectors.ts | 10 +- .../applications/endpoint/store/index.ts | 13 +- .../endpoint/store/policy_list/index.test.ts | 7 +- .../store/policy_list/test_mock_utils.ts | 115 +-------------- .../applications/endpoint/store/test_utils.ts | 126 ++++++++++++++++ .../public/applications/endpoint/types.ts | 1 + .../__snapshots__/page_view.test.tsx.snap | 8 + .../endpoint/view/components/page_view.tsx | 1 + .../details/components/flyout_sub_header.tsx | 72 +++++++++ .../{details.tsx => details/host_details.tsx} | 137 +++++++----------- .../endpoint/view/hosts/details/index.tsx | 134 +++++++++++++++++ .../view/hosts/details/policy_response.tsx | 10 ++ .../applications/endpoint/view/hosts/hooks.ts | 18 +++ .../endpoint/view/hosts/index.test.tsx | 65 ++++++++- .../apps/endpoint/host_list.ts | 2 +- 16 files changed, 516 insertions(+), 213 deletions(-) create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/store/test_utils.ts create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/components/flyout_sub_header.tsx rename x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/{details.tsx => details/host_details.tsx} (53%) create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/index.tsx create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response.tsx diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/mocks/app_context_render.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/mocks/app_context_render.tsx index 7cb1031ef9a09..639b1f7252d7f 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/mocks/app_context_render.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/mocks/app_context_render.tsx @@ -12,6 +12,7 @@ import { coreMock } from '../../../../../../../src/core/public/mocks'; import { EndpointPluginStartDependencies } from '../../../plugin'; import { depsStartMock } from './dependencies_start_mock'; import { AppRootProvider } from '../view/app_root_provider'; +import { createSpyMiddleware, MiddlewareActionSpyHelper } from '../store/test_utils'; type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult; @@ -23,6 +24,7 @@ export interface AppContextTestRender { history: ReturnType; coreStart: ReturnType; depsStart: EndpointPluginStartDependencies; + middlewareSpy: MiddlewareActionSpyHelper; /** * A wrapper around `AppRootContext` component. Uses the mocked modules as input to the * `AppRootContext` @@ -45,7 +47,12 @@ export const createAppRootMockRenderer = (): AppContextTestRender => { const history = createMemoryHistory(); const coreStart = coreMock.createStart({ basePath: '/mock' }); const depsStart = depsStartMock(); - const store = appStoreFactory({ coreStart, depsStart }); + const middlewareSpy = createSpyMiddleware(); + const store = appStoreFactory({ + coreStart, + depsStart, + additionalMiddleware: [middlewareSpy.actionSpyMiddleware], + }); const AppWrapper: React.FunctionComponent<{ children: React.ReactElement }> = ({ children }) => ( {children} @@ -64,6 +71,7 @@ export const createAppRootMockRenderer = (): AppContextTestRender => { history, coreStart, depsStart, + middlewareSpy, AppWrapper, render, }; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/selectors.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/selectors.ts index 35bf5d0616878..03cdba8505800 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/selectors.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/selectors.ts @@ -37,7 +37,7 @@ export const uiQueryParams: ( // Removes the `?` from the beginning of query string if it exists const query = querystring.parse(location.search.slice(1)); - const keys: Array = ['selected_host']; + const keys: Array = ['selected_host', 'show']; for (const key of keys) { const value = query[key]; @@ -58,3 +58,11 @@ export const hasSelectedHost: (state: Immutable) => boolean = cre return selectedHost !== undefined; } ); + +/** What policy details panel view to show */ +export const showView: (state: HostListState) => 'policy_response' | 'details' = createSelector( + uiQueryParams, + searchParams => { + return searchParams.show === 'policy_response' ? 'policy_response' : 'details'; + } +); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts index efa79b163d3b6..60758f0f5fea0 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts @@ -19,7 +19,7 @@ import { alertMiddlewareFactory } from './alerts/middleware'; import { hostMiddlewareFactory } from './hosts'; import { policyListMiddlewareFactory } from './policy_list'; import { policyDetailsMiddlewareFactory } from './policy_details'; -import { GlobalState } from '../types'; +import { GlobalState, MiddlewareFactory } from '../types'; import { AppAction } from './action'; import { EndpointPluginStartDependencies } from '../../../plugin'; @@ -62,10 +62,15 @@ export const appStoreFactory: (middlewareDeps?: { * Give middleware access to plugin start dependencies. */ depsStart: EndpointPluginStartDependencies; + /** + * Any additional Redux Middlewares + * (should only be used for testing - example: to inject the action spy middleware) + */ + additionalMiddleware?: Array>; }) => Store = middlewareDeps => { let middleware; if (middlewareDeps) { - const { coreStart, depsStart } = middlewareDeps; + const { coreStart, depsStart, additionalMiddleware = [] } = middlewareDeps; middleware = composeWithReduxDevTools( applyMiddleware( substateMiddlewareFactory( @@ -83,7 +88,9 @@ export const appStoreFactory: (middlewareDeps?: { substateMiddlewareFactory( globalState => globalState.alertList, alertMiddlewareFactory(coreStart, depsStart) - ) + ), + // Additional Middleware should go last + ...additionalMiddleware ) ); } else { diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/index.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/index.test.ts index 97a2b65fb65f8..69b11fb3c1f0e 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/index.test.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/index.test.ts @@ -12,13 +12,10 @@ import { policyListMiddlewareFactory } from './middleware'; import { coreMock } from '../../../../../../../../src/core/public/mocks'; import { isOnPolicyListPage, selectIsLoading, urlSearchParams } from './selectors'; import { DepsStartMock, depsStartMock } from '../../mocks'; -import { - createSpyMiddleware, - MiddlewareActionSpyHelper, - setPolicyListApiMockImplementation, -} from './test_mock_utils'; +import { setPolicyListApiMockImplementation } from './test_mock_utils'; import { INGEST_API_DATASOURCES } from './services/ingest'; import { Immutable } from '../../../../../common/types'; +import { createSpyMiddleware, MiddlewareActionSpyHelper } from '../test_utils'; describe('policy list store concerns', () => { let fakeCoreStart: ReturnType; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/test_mock_utils.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/test_mock_utils.ts index 20d5a637182d2..a1788b8f8021d 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/test_mock_utils.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_list/test_mock_utils.ts @@ -5,10 +5,9 @@ */ import { HttpStart } from 'kibana/public'; -import { Dispatch } from 'redux'; import { INGEST_API_DATASOURCES } from './services/ingest'; import { EndpointDocGenerator } from '../../../../../common/generate_data'; -import { AppAction, GetPolicyListResponse, GlobalState, MiddlewareFactory } from '../../types'; +import { GetPolicyListResponse } from '../../types'; const generator = new EndpointDocGenerator('policy-list'); @@ -37,115 +36,3 @@ export const setPolicyListApiMockImplementation = ( return Promise.reject(new Error(`MOCK: unknown policy list api: ${path}`)); }); }; - -/** - * Utilities for testing Redux middleware - */ -export interface MiddlewareActionSpyHelper { - /** - * Returns a promise that is fulfilled when the given action is dispatched or a timeout occurs. - * The use of this method instead of a `sleep()` type of delay should avoid test case instability - * especially when run in a CI environment. - * - * @param actionType - */ - waitForAction: (actionType: AppAction['type']) => Promise; - /** - * A property holding the information around the calls that were processed by the internal - * `actionSpyMiddlware`. This property holds the information typically found in Jets's mocked - * function `mock` property - [see here for more information](https://jestjs.io/docs/en/mock-functions#mock-property) - * - * **Note**: this property will only be set **after* the `actionSpyMiddlware` has been - * initialized (ex. via `createStore()`. Attempting to reference this property before that time - * will throw an error. - * Also - do not hold on to references to this property value if `jest.clearAllMocks()` or - * `jest.resetAllMocks()` is called between usages of the value. - */ - dispatchSpy: jest.Mock>['mock']; - /** - * Redux middleware that enables spying on the action that are dispatched through the store - */ - actionSpyMiddleware: ReturnType>; -} - -/** - * Creates a new instance of middleware action helpers - * Note: in most cases (testing concern specific middleware) this function should be given - * the state type definition, else, the global state will be used. - * - * @example - * // Use in Policy List middleware testing - * const middlewareSpyUtils = createSpyMiddleware(); - * store = createStore( - * policyListReducer, - * applyMiddleware( - * policyListMiddlewareFactory(fakeCoreStart, depsStart), - * middlewareSpyUtils.actionSpyMiddleware - * ) - * ); - * // Reference `dispatchSpy` ONLY after creating the store that includes `actionSpyMiddleware` - * const { waitForAction, dispatchSpy } = middlewareSpyUtils; - * // - * // later in test - * // - * it('...', async () => { - * //... - * await waitForAction('serverReturnedPolicyListData'); - * // do assertions - * // or check how action was called - * expect(dispatchSpy.calls.length).toBe(2) - * }); - */ -export const createSpyMiddleware = (): MiddlewareActionSpyHelper => { - type ActionWatcher = (action: AppAction) => void; - - const watchers = new Set(); - let spyDispatch: jest.Mock>; - - return { - waitForAction: async (actionType: string) => { - // Error is defined here so that we get a better stack trace that points to the test from where it was used - const err = new Error(`action '${actionType}' was not dispatched within the allocated time`); - - await new Promise((resolve, reject) => { - const watch: ActionWatcher = action => { - if (action.type === actionType) { - watchers.delete(watch); - clearTimeout(timeout); - resolve(); - } - }; - - // We timeout before jest's default 5s, so that a better error stack is returned - const timeout = setTimeout(() => { - watchers.delete(watch); - reject(err); - }, 4500); - watchers.add(watch); - }); - }, - - get dispatchSpy() { - if (!spyDispatch) { - throw new Error( - 'Spy Middleware has not been initialized. Access this property only after using `actionSpyMiddleware` in a redux store' - ); - } - return spyDispatch.mock; - }, - - actionSpyMiddleware: api => { - return next => { - spyDispatch = jest.fn(action => { - next(action); - // loop through the list of watcher (if any) and call them with this action - for (const watch of watchers) { - watch(action); - } - return action; - }); - return spyDispatch; - }; - }, - }; -}; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/test_utils.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/test_utils.ts new file mode 100644 index 0000000000000..99e14cef73e8b --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/test_utils.ts @@ -0,0 +1,126 @@ +/* + * 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 { Dispatch } from 'redux'; +import { AppAction, GlobalState, MiddlewareFactory } from '../types'; + +/** + * Utilities for testing Redux middleware + */ +export interface MiddlewareActionSpyHelper { + /** + * Returns a promise that is fulfilled when the given action is dispatched or a timeout occurs. + * The `action` will given to the promise `resolve` thus allowing for checks to be done. + * The use of this method instead of a `sleep()` type of delay should avoid test case instability + * especially when run in a CI environment. + * + * @param actionType + */ + waitForAction: (actionType: T) => Promise; + /** + * A property holding the information around the calls that were processed by the internal + * `actionSpyMiddelware`. This property holds the information typically found in Jets's mocked + * function `mock` property - [see here for more information](https://jestjs.io/docs/en/mock-functions#mock-property) + * + * **Note**: this property will only be set **after* the `actionSpyMiddlware` has been + * initialized (ex. via `createStore()`. Attempting to reference this property before that time + * will throw an error. + * Also - do not hold on to references to this property value if `jest.clearAllMocks()` or + * `jest.resetAllMocks()` is called between usages of the value. + */ + dispatchSpy: jest.Mock>['mock']; + /** + * Redux middleware that enables spying on the action that are dispatched through the store + */ + actionSpyMiddleware: ReturnType>; +} + +/** + * Creates a new instance of middleware action helpers + * Note: in most cases (testing concern specific middleware) this function should be given + * the state type definition, else, the global state will be used. + * + * @example + * // Use in Policy List middleware testing + * const middlewareSpyUtils = createSpyMiddleware(); + * store = createStore( + * policyListReducer, + * applyMiddleware( + * policyListMiddlewareFactory(fakeCoreStart, depsStart), + * middlewareSpyUtils.actionSpyMiddleware + * ) + * ); + * // Reference `dispatchSpy` ONLY after creating the store that includes `actionSpyMiddleware` + * const { waitForAction, dispatchSpy } = middlewareSpyUtils; + * // + * // later in test + * // + * it('...', async () => { + * //... + * await waitForAction('serverReturnedPolicyListData'); + * // do assertions + * // or check how action was called + * expect(dispatchSpy.calls.length).toBe(2) + * }); + */ +export const createSpyMiddleware = < + S = GlobalState, + A extends AppAction = AppAction +>(): MiddlewareActionSpyHelper => { + type ActionWatcher = (action: A) => void; + + const watchers = new Set(); + let spyDispatch: jest.Mock>; + + return { + waitForAction: async actionType => { + type ResolvedAction = A extends { type: typeof actionType } ? A : never; + + // Error is defined here so that we get a better stack trace that points to the test from where it was used + const err = new Error(`action '${actionType}' was not dispatched within the allocated time`); + + return new Promise((resolve, reject) => { + const watch: ActionWatcher = action => { + if (action.type === actionType) { + watchers.delete(watch); + clearTimeout(timeout); + resolve(action as ResolvedAction); + } + }; + + // We timeout before jest's default 5s, so that a better error stack is returned + const timeout = setTimeout(() => { + watchers.delete(watch); + reject(err); + }, 4500); + watchers.add(watch); + }); + }, + + get dispatchSpy() { + if (!spyDispatch) { + throw new Error( + 'Spy Middleware has not been initialized. Access this property only after using `actionSpyMiddleware` in a redux store' + ); + } + return spyDispatch.mock; + }, + + actionSpyMiddleware: api => { + return next => { + spyDispatch = jest.fn(action => { + next(action); + // loop through the list of watcher (if any) and call them with this action + for (const watch of watchers) { + watch(action); + } + return action; + }); + return spyDispatch; + }; + }, + }; +}; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts index 7aca94d3e9c7c..59cd8f806e5b0 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts @@ -52,6 +52,7 @@ export interface HostListPagination { } export interface HostIndexUIQueryParams { selected_host?: string; + show?: string; } export interface ServerApiError { diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/components/__snapshots__/page_view.test.tsx.snap b/x-pack/plugins/endpoint/public/applications/endpoint/view/components/__snapshots__/page_view.test.tsx.snap index dfc69fc46ebdc..36b602a1e6784 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/components/__snapshots__/page_view.test.tsx.snap +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/components/__snapshots__/page_view.test.tsx.snap @@ -7,6 +7,7 @@ exports[`PageView component should display body header custom element 1`] = ` .c0.endpoint--isListView .endpoint-header { padding: 24px; + margin-bottom: 0; } .c0.endpoint--isListView .endpoint-page-content { @@ -97,6 +98,7 @@ exports[`PageView component should display body header wrapped in EuiTitle 1`] = .c0.endpoint--isListView .endpoint-header { padding: 24px; + margin-bottom: 0; } .c0.endpoint--isListView .endpoint-page-content { @@ -190,6 +192,7 @@ exports[`PageView component should display header left and right 1`] = ` .c0.endpoint--isListView .endpoint-header { padding: 24px; + margin-bottom: 0; } .c0.endpoint--isListView .endpoint-page-content { @@ -298,6 +301,7 @@ exports[`PageView component should display only body if not header props used 1` .c0.endpoint--isListView .endpoint-header { padding: 24px; + margin-bottom: 0; } .c0.endpoint--isListView .endpoint-page-content { @@ -365,6 +369,7 @@ exports[`PageView component should display only header left 1`] = ` .c0.endpoint--isListView .endpoint-header { padding: 24px; + margin-bottom: 0; } .c0.endpoint--isListView .endpoint-page-content { @@ -462,6 +467,7 @@ exports[`PageView component should display only header right but include an empt .c0.endpoint--isListView .endpoint-header { padding: 24px; + margin-bottom: 0; } .c0.endpoint--isListView .endpoint-page-content { @@ -556,6 +562,7 @@ exports[`PageView component should pass through EuiPage props 1`] = ` .c0.endpoint--isListView .endpoint-header { padding: 24px; + margin-bottom: 0; } .c0.endpoint--isListView .endpoint-page-content { @@ -640,6 +647,7 @@ exports[`PageView component should use custom element for header left and not wr .c0.endpoint--isListView .endpoint-header { padding: 24px; + margin-bottom: 0; } .c0.endpoint--isListView .endpoint-page-content { diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/components/page_view.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/components/page_view.tsx index 561d671e18e07..6da352b68f890 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/components/page_view.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/components/page_view.tsx @@ -25,6 +25,7 @@ const StyledEuiPage = styled(EuiPage)` .endpoint-header { padding: ${props => props.theme.eui.euiSizeL}; + margin-bottom: 0; } .endpoint-page-content { border-left: none; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/components/flyout_sub_header.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/components/flyout_sub_header.tsx new file mode 100644 index 0000000000000..26f2203790a9e --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/components/flyout_sub_header.tsx @@ -0,0 +1,72 @@ +/* + * 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 { EuiFlyoutHeader, CommonProps, EuiButtonEmpty } from '@elastic/eui'; +import styled from 'styled-components'; + +export type FlyoutSubHeaderProps = CommonProps & { + children: React.ReactNode; + backButton?: { + title: string; + onClick: (event: React.MouseEvent) => void; + href?: string; + }; +}; + +const StyledEuiFlyoutHeader = styled(EuiFlyoutHeader)` + padding: ${props => props.theme.eui.paddingSizes.s}; + + &.hasButtons { + .buttons { + padding-bottom: ${props => props.theme.eui.paddingSizes.s}; + } + + .back-button-content { + padding-left: 0; + &-text { + margin-left: 0; + } + } + } + + .flyout-content { + padding-left: ${props => props.theme.eui.paddingSizes.m}; + } +`; + +const BUTTON_CONTENT_PROPS = Object.freeze({ className: 'back-button-content' }); +const BUTTON_TEXT_PROPS = Object.freeze({ className: 'back-button-content-text' }); + +/** + * A Eui Flyout Header component that has its styles adjusted to display a panel sub-header. + * Component also provides a way to display a "back" button above the header title. + */ +export const FlyoutSubHeader = memo( + ({ children, backButton, ...otherProps }) => { + return ( + + {backButton && ( +
+ {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + {backButton?.title} + +
+ )} +
{children}
+
+ ); + } +); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/host_details.tsx similarity index 53% rename from x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details.tsx rename to x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/host_details.tsx index f51349b24933a..32c69426b03f3 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/host_details.tsx @@ -4,31 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useMemo, memo, useEffect } from 'react'; +import styled from 'styled-components'; import { - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiTitle, EuiDescriptionList, - EuiLoadingContent, - EuiHorizontalRule, EuiHealth, - EuiSpacer, + EuiHorizontalRule, + EuiLink, EuiListGroup, EuiListGroupItem, } from '@elastic/eui'; -import { useHistory } from 'react-router-dom'; -import styled from 'styled-components'; -import { i18n } from '@kbn/i18n'; +import React, { memo, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -import { HostMetadata } from '../../../../../common/types'; -import { useHostListSelector } from './hooks'; -import { urlFromQueryParams } from './url_from_query_params'; -import { FormattedDateAndTime } from '../formatted_date_time'; -import { uiQueryParams, detailsData, detailsError } from './../../store/hosts/selectors'; -import { LinkToApp } from '../components/link_to_app'; +import { i18n } from '@kbn/i18n'; +import { useHistory } from 'react-router-dom'; +import { HostMetadata } from '../../../../../../common/types'; +import { FormattedDateAndTime } from '../../formatted_date_time'; +import { LinkToApp } from '../../components/link_to_app'; +import { useHostListSelector, useHostLogsUrl } from '../hooks'; +import { urlFromQueryParams } from '../url_from_query_params'; +import { uiQueryParams } from '../../../store/hosts/selectors'; const HostIds = styled(EuiListGroupItem)` margin-top: 0; @@ -37,8 +31,10 @@ const HostIds = styled(EuiListGroupItem)` } `; -const HostDetails = memo(({ details }: { details: HostMetadata }) => { +export const HostDetails = memo(({ details }: { details: HostMetadata }) => { const { appId, appPath, url } = useHostLogsUrl(details.host.id); + const queryParams = useHostListSelector(uiQueryParams); + const history = useHistory(); const detailsResultsUpper = useMemo(() => { return [ { @@ -62,6 +58,14 @@ const HostDetails = memo(({ details }: { details: HostMetadata }) => { ]; }, [details]); + const policyResponseUri = useMemo(() => { + return urlFromQueryParams({ + ...queryParams, + selected_host: details.host.id, + show: 'policy_response', + }); + }, [details.host.id, queryParams]); + const detailsResultsLower = useMemo(() => { return [ { @@ -74,7 +78,24 @@ const HostDetails = memo(({ details }: { details: HostMetadata }) => { title: i18n.translate('xpack.endpoint.host.details.policyStatus', { defaultMessage: 'Policy Status', }), - description: active, + description: ( + + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + { + ev.preventDefault(); + history.push(policyResponseUri); + }} + > + + + + ), }, { title: i18n.translate('xpack.endpoint.host.details.ipAddress', { @@ -101,7 +122,15 @@ const HostDetails = memo(({ details }: { details: HostMetadata }) => { description: details.agent.version, }, ]; - }, [details.agent.version, details.endpoint.policy.id, details.host.hostname, details.host.ip]); + }, [ + details.agent.version, + details.endpoint.policy.id, + details.host.hostname, + details.host.ip, + history, + policyResponseUri, + ]); + return ( <> { ); }); - -export const HostDetailsFlyout = () => { - const history = useHistory(); - const { notifications } = useKibana(); - const queryParams = useHostListSelector(uiQueryParams); - const { selected_host: selectedHost, ...queryParamsWithoutSelectedHost } = queryParams; - const details = useHostListSelector(detailsData); - const error = useHostListSelector(detailsError); - - const handleFlyoutClose = useCallback(() => { - history.push(urlFromQueryParams(queryParamsWithoutSelectedHost)); - }, [history, queryParamsWithoutSelectedHost]); - - useEffect(() => { - if (error !== undefined) { - notifications.toasts.danger({ - title: ( - - ), - body: ( - - ), - toastLifeTimeMs: 10000, - }); - } - }, [error, notifications.toasts]); - - return ( - - - -

- {details === undefined ? : details.host.hostname} -

-
-
- - {details === undefined ? ( - <> - - - ) : ( - - )} - -
- ); -}; - -const useHostLogsUrl = (hostId: string): { url: string; appId: string; appPath: string } => { - const { services } = useKibana(); - return useMemo(() => { - const appPath = `/stream?logFilter=(expression:'host.id:${hostId}',kind:kuery)`; - return { - url: `${services.application.getUrlForApp('logs')}${appPath}`, - appId: 'logs', - appPath, - }; - }, [hostId, services.application]); -}; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/index.tsx new file mode 100644 index 0000000000000..a41d4a968f177 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/index.tsx @@ -0,0 +1,134 @@ +/* + * 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, useEffect, memo, useMemo } from 'react'; +import { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, + EuiLoadingContent, + EuiSpacer, +} from '@elastic/eui'; +import { useHistory } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; +import { useHostListSelector } from '../hooks'; +import { urlFromQueryParams } from '../url_from_query_params'; +import { uiQueryParams, detailsData, detailsError, showView } from '../../../store/hosts/selectors'; +import { HostDetails } from './host_details'; +import { PolicyResponse } from './policy_response'; +import { HostMetadata } from '../../../../../../common/types'; +import { FlyoutSubHeader, FlyoutSubHeaderProps } from './components/flyout_sub_header'; + +export const HostDetailsFlyout = memo(() => { + const history = useHistory(); + const { notifications } = useKibana(); + const queryParams = useHostListSelector(uiQueryParams); + const { selected_host: selectedHost, ...queryParamsWithoutSelectedHost } = queryParams; + const details = useHostListSelector(detailsData); + const error = useHostListSelector(detailsError); + const show = useHostListSelector(showView); + + const handleFlyoutClose = useCallback(() => { + history.push(urlFromQueryParams(queryParamsWithoutSelectedHost)); + }, [history, queryParamsWithoutSelectedHost]); + + useEffect(() => { + if (error !== undefined) { + notifications.toasts.danger({ + title: ( + + ), + body: ( + + ), + toastLifeTimeMs: 10000, + }); + } + }, [error, notifications.toasts]); + + return ( + + + +

+ {details === undefined ? : details.host.hostname} +

+
+
+ {details === undefined ? ( + <> + + + + + ) : ( + <> + {show === 'details' && ( + <> + + + + + )} + {show === 'policy_response' && } + + )} +
+ ); +}); + +const PolicyResponseFlyoutPanel = memo<{ + hostMeta: HostMetadata; +}>(({ hostMeta }) => { + const history = useHistory(); + const { show, ...queryParams } = useHostListSelector(uiQueryParams); + const backButtonProp = useMemo((): FlyoutSubHeaderProps['backButton'] => { + const detailsUri = urlFromQueryParams({ + ...queryParams, + selected_host: hostMeta.host.id, + }); + return { + title: i18n.translate('xpack.endpoint.host.policyResponse.backLinkTitle', { + defaultMessage: 'Endpoint Details', + }), + href: '?' + detailsUri.search, + onClick: ev => { + ev.preventDefault(); + history.push(detailsUri); + }, + }; + }, [history, hostMeta.host.id, queryParams]); + + return ( + <> + + +

+ +

+
+
+ + + + + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response.tsx new file mode 100644 index 0000000000000..eacb6a52d3184 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response.tsx @@ -0,0 +1,10 @@ +/* + * 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'; + +export const PolicyResponse = memo(() => { + return
Policy Status to be displayed here soon.
; +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/hooks.ts b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/hooks.ts index 99a0073f46c74..7eb51f3a7b294 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/hooks.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/hooks.ts @@ -5,10 +5,28 @@ */ import { useSelector } from 'react-redux'; +import { useMemo } from 'react'; import { GlobalState, HostListState } from '../../types'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; export function useHostListSelector(selector: (state: HostListState) => TSelected) { return useSelector(function(state: GlobalState) { return selector(state.hostList); }); } + +/** + * Returns an object that contains Kibana Logs app and URL information for a given host id + * @param hostId + */ +export const useHostLogsUrl = (hostId: string): { url: string; appId: string; appPath: string } => { + const { services } = useKibana(); + return useMemo(() => { + const appPath = `/stream?logFilter=(expression:'host.id:${hostId}',kind:kuery)`; + return { + url: `${services.application.getUrlForApp('logs')}${appPath}`, + appId: 'logs', + appPath, + }; + }, [hostId, services.application]); +}; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx index d2d0ad40b025f..88416b577ed0c 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx @@ -21,10 +21,11 @@ describe('when on the hosts page', () => { let history: AppContextTestRender['history']; let store: AppContextTestRender['store']; let coreStart: AppContextTestRender['coreStart']; + let middlewareSpy: AppContextTestRender['middlewareSpy']; beforeEach(async () => { const mockedContext = createAppRootMockRenderer(); - ({ history, store, coreStart } = mockedContext); + ({ history, store, coreStart, middlewareSpy } = mockedContext); render = () => mockedContext.render(); }); @@ -132,6 +133,25 @@ describe('when on the hosts page', () => { expect(flyout).not.toBeNull(); }); }); + it('should display policy status value as a link', async () => { + const renderResult = render(); + const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); + expect(policyStatusLink).not.toBeNull(); + expect(policyStatusLink.textContent).toEqual('Successful'); + expect(policyStatusLink.getAttribute('href')).toEqual( + '?selected_host=1&show=policy_response' + ); + }); + it('should update the URL when policy status link is clicked', async () => { + const renderResult = render(); + const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); + const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl'); + reactTestingLibrary.act(() => { + fireEvent.click(policyStatusLink); + }); + const changedUrlAction = await userChangedUrlChecker; + expect(changedUrlAction.payload.search).toEqual('?selected_host=1&show=policy_response'); + }); it('should include the link to logs', async () => { const renderResult = render(); const linkToLogs = await renderResult.findByTestId('hostDetailsLinkToLogs'); @@ -154,5 +174,48 @@ describe('when on the hosts page', () => { expect(coreStart.application.navigateToApp.mock.calls).toHaveLength(1); }); }); + describe('when showing host Policy Response', () => { + let renderResult: ReturnType; + beforeEach(async () => { + renderResult = render(); + const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); + const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl'); + reactTestingLibrary.act(() => { + fireEvent.click(policyStatusLink); + }); + await userChangedUrlChecker; + }); + it('should hide the host details panel', async () => { + const hostDetailsFlyout = await renderResult.queryByTestId('hostDetailsFlyoutBody'); + expect(hostDetailsFlyout).toBeNull(); + }); + it('should display policy response sub-panel', async () => { + expect( + await renderResult.findByTestId('hostDetailsPolicyResponseFlyoutHeader') + ).not.toBeNull(); + expect( + await renderResult.findByTestId('hostDetailsPolicyResponseFlyoutBody') + ).not.toBeNull(); + }); + it('should include the sub-panel title', async () => { + expect( + (await renderResult.findByTestId('hostDetailsPolicyResponseFlyoutTitle')).textContent + ).toBe('Policy Response'); + }); + it('should include the back to details link', async () => { + const subHeaderBackLink = await renderResult.findByTestId('flyoutSubHeaderBackButton'); + expect(subHeaderBackLink.textContent).toBe('Endpoint Details'); + expect(subHeaderBackLink.getAttribute('href')).toBe('?selected_host=1'); + }); + it('should update URL when back to details link is clicked', async () => { + const subHeaderBackLink = await renderResult.findByTestId('flyoutSubHeaderBackButton'); + const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl'); + reactTestingLibrary.act(() => { + fireEvent.click(subHeaderBackLink); + }); + const changedUrlAction = await userChangedUrlChecker; + expect(changedUrlAction.payload.search).toEqual('?selected_host=1'); + }); + }); }); }); diff --git a/x-pack/test/functional_endpoint/apps/endpoint/host_list.ts b/x-pack/test/functional_endpoint/apps/endpoint/host_list.ts index 2e204775808c9..9a4ffecf85d52 100644 --- a/x-pack/test/functional_endpoint/apps/endpoint/host_list.ts +++ b/x-pack/test/functional_endpoint/apps/endpoint/host_list.ts @@ -167,7 +167,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { '', '0', '00000000-0000-0000-0000-000000000000', - 'active', + 'Successful', '10.101.149.262606:a000:ffc0:39:11ef:37b9:3371:578c', 'rezzani-7.example.com', '6.8.0',