[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
This commit is contained in:
Paul Tavares 2020-04-17 10:58:01 -04:00 committed by GitHub
parent a1039fc1c1
commit f2c0e69dbc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 516 additions and 213 deletions

View file

@ -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<typeof createMemoryHistory>;
coreStart: ReturnType<typeof coreMock.createStart>;
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<never>();
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 }) => (
<AppRootProvider store={store} history={history} coreStart={coreStart} depsStart={depsStart}>
{children}
@ -64,6 +71,7 @@ export const createAppRootMockRenderer = (): AppContextTestRender => {
history,
coreStart,
depsStart,
middlewareSpy,
AppWrapper,
render,
};

View file

@ -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<keyof HostIndexUIQueryParams> = ['selected_host'];
const keys: Array<keyof HostIndexUIQueryParams> = ['selected_host', 'show'];
for (const key of keys) {
const value = query[key];
@ -58,3 +58,11 @@ export const hasSelectedHost: (state: Immutable<HostListState>) => 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';
}
);

View file

@ -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<ReturnType<MiddlewareFactory>>;
}) => 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 {

View file

@ -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<typeof coreMock.createStart>;

View file

@ -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<S = GlobalState> {
/**
* 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<void>;
/**
* 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<Dispatch<AppAction>>['mock'];
/**
* Redux middleware that enables spying on the action that are dispatched through the store
*/
actionSpyMiddleware: ReturnType<MiddlewareFactory<S>>;
}
/**
* 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<PolicyListState>();
* 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>(): MiddlewareActionSpyHelper<S> => {
type ActionWatcher = (action: AppAction) => void;
const watchers = new Set<ActionWatcher>();
let spyDispatch: jest.Mock<Dispatch<AppAction>>;
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;
};
},
};
};

View file

@ -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<S = GlobalState, A extends AppAction = AppAction> {
/**
* 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: <T extends A['type']>(actionType: T) => Promise<A extends { type: T } ? A : never>;
/**
* 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<Dispatch<A>>['mock'];
/**
* Redux middleware that enables spying on the action that are dispatched through the store
*/
actionSpyMiddleware: ReturnType<MiddlewareFactory<S>>;
}
/**
* 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<PolicyListState>();
* 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<S, A> => {
type ActionWatcher = (action: A) => void;
const watchers = new Set<ActionWatcher>();
let spyDispatch: jest.Mock<Dispatch<A>>;
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<ResolvedAction>((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;
};
},
};
};

View file

@ -52,6 +52,7 @@ export interface HostListPagination {
}
export interface HostIndexUIQueryParams {
selected_host?: string;
show?: string;
}
export interface ServerApiError {

View file

@ -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 {

View file

@ -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;

View file

@ -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<FlyoutSubHeaderProps>(
({ children, backButton, ...otherProps }) => {
return (
<StyledEuiFlyoutHeader hasBorder {...otherProps} className={backButton && `hasButtons`}>
{backButton && (
<div className="buttons">
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
<EuiButtonEmpty
data-test-subj="flyoutSubHeaderBackButton"
iconType="arrowLeft"
contentProps={BUTTON_CONTENT_PROPS}
textProps={BUTTON_TEXT_PROPS}
size="xs"
href={backButton?.href ?? ''}
onClick={backButton?.onClick}
>
{backButton?.title}
</EuiButtonEmpty>
</div>
)}
<div className={'flyout-content'}>{children}</div>
</StyledEuiFlyoutHeader>
);
}
);

View file

@ -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: <EuiHealth color="success">active</EuiHealth>,
description: (
<EuiHealth color="success">
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
<EuiLink
data-test-subj="policyStatusValue"
href={'?' + policyResponseUri.search}
onClick={(ev: React.MouseEvent) => {
ev.preventDefault();
history.push(policyResponseUri);
}}
>
<FormattedMessage
id="xpack.endpoint.host.details.policyStatus.success"
defaultMessage="Successful"
/>
</EuiLink>
</EuiHealth>
),
},
{
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 (
<>
<EuiDescriptionList
@ -132,69 +161,3 @@ const HostDetails = memo(({ details }: { details: HostMetadata }) => {
</>
);
});
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: (
<FormattedMessage
id="xpack.endpoint.host.details.errorTitle"
defaultMessage="Could not find host"
/>
),
body: (
<FormattedMessage
id="xpack.endpoint.host.details.errorBody"
defaultMessage="Please exit the flyout and select an available host."
/>
),
toastLifeTimeMs: 10000,
});
}
}, [error, notifications.toasts]);
return (
<EuiFlyout onClose={handleFlyoutClose} data-test-subj="hostDetailsFlyout">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h2 data-test-subj="hostDetailsFlyoutTitle">
{details === undefined ? <EuiLoadingContent lines={1} /> : details.host.hostname}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
{details === undefined ? (
<>
<EuiLoadingContent lines={3} /> <EuiSpacer size="l" /> <EuiLoadingContent lines={3} />
</>
) : (
<HostDetails details={details} />
)}
</EuiFlyoutBody>
</EuiFlyout>
);
};
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]);
};

View file

@ -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: (
<FormattedMessage
id="xpack.endpoint.host.details.errorTitle"
defaultMessage="Could not find host"
/>
),
body: (
<FormattedMessage
id="xpack.endpoint.host.details.errorBody"
defaultMessage="Please exit the flyout and select an available host."
/>
),
toastLifeTimeMs: 10000,
});
}
}, [error, notifications.toasts]);
return (
<EuiFlyout onClose={handleFlyoutClose} data-test-subj="hostDetailsFlyout" size="s">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h2 data-test-subj="hostDetailsFlyoutTitle">
{details === undefined ? <EuiLoadingContent lines={1} /> : details.host.hostname}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
{details === undefined ? (
<>
<EuiFlyoutBody>
<EuiLoadingContent lines={3} /> <EuiSpacer size="l" /> <EuiLoadingContent lines={3} />
</EuiFlyoutBody>
</>
) : (
<>
{show === 'details' && (
<>
<EuiFlyoutBody data-test-subj="hostDetailsFlyoutBody">
<HostDetails details={details} />
</EuiFlyoutBody>
</>
)}
{show === 'policy_response' && <PolicyResponseFlyoutPanel hostMeta={details} />}
</>
)}
</EuiFlyout>
);
});
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 (
<>
<FlyoutSubHeader
backButton={backButtonProp}
data-test-subj="hostDetailsPolicyResponseFlyoutHeader"
>
<EuiTitle size="xxs" data-test-subj="hostDetailsPolicyResponseFlyoutTitle">
<h3>
<FormattedMessage
id="xpack.endpoint.host.policyResponse.title"
defaultMessage="Policy Response"
/>
</h3>
</EuiTitle>
</FlyoutSubHeader>
<EuiFlyoutBody data-test-subj="hostDetailsPolicyResponseFlyoutBody">
<PolicyResponse />
</EuiFlyoutBody>
</>
);
});

View file

@ -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 <div>Policy Status to be displayed here soon.</div>;
});

View file

@ -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<TSelected>(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]);
};

View file

@ -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(<HostList />);
});
@ -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<typeof render>;
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');
});
});
});
});

View file

@ -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',