[Endpoint] add policy data to Host list UI (#69202)

This commit is contained in:
Kevin Logan 2020-06-17 11:27:00 -04:00 committed by GitHub
parent b8e6b54428
commit 1cef65e56f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 191 additions and 32 deletions

View file

@ -93,13 +93,40 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => {
const policyStatusClickHandler = useNavigateByRouterEventHandler(policyResponseRoutePath);
const [policyDetailsRoutePath, policyDetailsRouteUrl] = useMemo(() => {
return [
getManagementUrl({
name: 'policyDetails',
policyId: details.endpoint.policy.applied.id,
excludePrefix: true,
}),
getManagementUrl({
name: 'policyDetails',
policyId: details.endpoint.policy.applied.id,
}),
];
}, [details.endpoint.policy.applied.id]);
const policyDetailsClickHandler = useNavigateByRouterEventHandler(policyDetailsRoutePath);
const detailsResultsPolicy = useMemo(() => {
return [
{
title: i18n.translate('xpack.securitySolution.endpoint.host.details.policy', {
defaultMessage: 'Policy',
}),
description: details.endpoint.policy.applied.id,
description: (
<>
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
<EuiLink
data-test-subj="policyDetailsValue"
href={policyDetailsRouteUrl}
onClick={policyDetailsClickHandler}
>
{details.endpoint.policy.applied.name}
</EuiLink>
</>
),
},
{
title: i18n.translate('xpack.securitySolution.endpoint.host.details.policyStatus', {
@ -128,7 +155,14 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => {
),
},
];
}, [details, policyResponseUri, policyStatus, policyStatusClickHandler]);
}, [
details,
policyResponseUri,
policyStatus,
policyStatusClickHandler,
policyDetailsRouteUrl,
policyDetailsClickHandler,
]);
const detailsResultsLower = useMemo(() => {
return [
{

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { HostStatus, HostPolicyResponseActionStatus } from '../../../../../common/endpoint/types';
export const HOST_STATUS_TO_HEALTH_COLOR = Object.freeze<
@ -23,3 +24,17 @@ export const POLICY_STATUS_TO_HEALTH_COLOR = Object.freeze<
warning: 'warning',
failure: 'danger',
});
export const POLICY_STATUS_TO_TEXT = Object.freeze<
{ [key in keyof typeof HostPolicyResponseActionStatus]: string }
>({
success: i18n.translate('xpack.securitySolution.policyStatusText.success', {
defaultMessage: 'Success',
}),
warning: i18n.translate('xpack.securitySolution.policyStatusText.warning', {
defaultMessage: 'Warning',
}),
failure: i18n.translate('xpack.securitySolution.policyStatusText.failure', {
defaultMessage: 'Failure',
}),
});

View file

@ -9,6 +9,7 @@ import * as reactTestingLibrary from '@testing-library/react';
import { HostList } from './index';
import { mockHostDetailsApiResult, mockHostResultList } from '../store/mock_host_result_list';
import { mockPolicyResultList } from '../../policy/store/policy_list/mock_policy_result_list';
import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint';
import {
HostInfo,
@ -17,6 +18,7 @@ import {
} from '../../../../../common/endpoint/types';
import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data';
import { AppAction } from '../../../../common/store/actions';
import { POLICY_STATUS_TO_HEALTH_COLOR, POLICY_STATUS_TO_TEXT } from './host_constants';
describe('when on the hosts page', () => {
const docGenerator = new EndpointDocGenerator();
@ -47,15 +49,23 @@ describe('when on the hosts page', () => {
});
});
describe('when list data loads', () => {
const generatedPolicyStatuses: Array<
HostInfo['metadata']['endpoint']['policy']['applied']['status']
> = [];
let firstPolicyID: string;
beforeEach(() => {
reactTestingLibrary.act(() => {
const hostListData = mockHostResultList({ total: 3 });
firstPolicyID = hostListData.hosts[0].metadata.endpoint.policy.applied.id;
[HostStatus.ERROR, HostStatus.ONLINE, HostStatus.OFFLINE].forEach((status, index) => {
hostListData.hosts[index] = {
metadata: hostListData.hosts[index].metadata,
host_status: status,
};
});
hostListData.hosts.forEach((item, index) => {
generatedPolicyStatuses[index] = item.metadata.endpoint.policy.applied.status;
});
const action: AppAction = {
type: 'serverReturnedHostList',
payload: hostListData,
@ -92,6 +102,29 @@ describe('when on the hosts page', () => {
).not.toBeNull();
});
it('should display correct policy status', async () => {
const renderResult = render();
const policyStatuses = await renderResult.findAllByTestId('rowPolicyStatus');
policyStatuses.forEach((status, index) => {
expect(status.textContent).toEqual(POLICY_STATUS_TO_TEXT[generatedPolicyStatuses[index]]);
expect(
status.querySelector(
`[data-euiicon-type][color=${
POLICY_STATUS_TO_HEALTH_COLOR[generatedPolicyStatuses[index]]
}]`
)
).not.toBeNull();
});
});
it('should display policy name as a link', async () => {
const renderResult = render();
const firstPolicyName = (await renderResult.findAllByTestId('policyNameCellLink'))[0];
expect(firstPolicyName).not.toBeNull();
expect(firstPolicyName.getAttribute('href')).toContain(`policy/${firstPolicyID}`);
});
describe('when the user clicks the first hostname in the table', () => {
let renderResult: reactTestingLibrary.RenderResult;
beforeEach(async () => {
@ -197,6 +230,32 @@ describe('when on the hosts page', () => {
expect(flyout).not.toBeNull();
});
});
it('should display policy name value as a link', async () => {
const renderResult = render();
const policyDetailsLink = await renderResult.findByTestId('policyDetailsValue');
expect(policyDetailsLink).not.toBeNull();
expect(policyDetailsLink.getAttribute('href')).toEqual(
`#/management/policy/${hostDetails.metadata.endpoint.policy.applied.id}`
);
});
it('should update the URL when policy name link is clicked', async () => {
const policyItem = mockPolicyResultList({ total: 1 }).items[0];
coreStart.http.get.mockReturnValue(Promise.resolve({ item: policyItem }));
const renderResult = render();
const policyDetailsLink = await renderResult.findByTestId('policyDetailsValue');
const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl');
reactTestingLibrary.act(() => {
reactTestingLibrary.fireEvent.click(policyDetailsLink);
});
const changedUrlAction = await userChangedUrlChecker;
expect(changedUrlAction.payload.pathname).toEqual(
`/management/policy/${hostDetails.metadata.endpoint.policy.applied.id}`
);
});
it('should display policy status value as a link', async () => {
const renderResult = render();
const policyStatusLink = await renderResult.findByTestId('policyStatusValue');
@ -205,6 +264,7 @@ describe('when on the hosts page', () => {
'#/management/endpoints?page_index=0&page_size=10&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');
@ -217,6 +277,7 @@ describe('when on the hosts page', () => {
'?page_index=0&page_size=10&selected_host=1&show=policy_response'
);
});
it('should display Success overall policy status', async () => {
const renderResult = render();
reactTestingLibrary.act(() => {
@ -230,6 +291,7 @@ describe('when on the hosts page', () => {
policyStatusHealth.querySelector('[data-euiicon-type][color="success"]')
).not.toBeNull();
});
it('should display Warning overall policy status', async () => {
const renderResult = render();
reactTestingLibrary.act(() => {
@ -243,6 +305,7 @@ describe('when on the hosts page', () => {
policyStatusHealth.querySelector('[data-euiicon-type][color="warning"]')
).not.toBeNull();
});
it('should display Failed overall policy status', async () => {
const renderResult = render();
reactTestingLibrary.act(() => {
@ -256,6 +319,7 @@ describe('when on the hosts page', () => {
policyStatusHealth.querySelector('[data-euiicon-type][color="danger"]')
).not.toBeNull();
});
it('should display Unknown overall policy status', async () => {
const renderResult = render();
reactTestingLibrary.act(() => {
@ -269,6 +333,7 @@ describe('when on the hosts page', () => {
policyStatusHealth.querySelector('[data-euiicon-type][color="subdued"]')
).not.toBeNull();
});
it('should include the link to logs', async () => {
const renderResult = render();
const linkToLogs = await renderResult.findByTestId('hostDetailsLinkToLogs');
@ -278,6 +343,7 @@ describe('when on the hosts page', () => {
"/app/logs/stream?logFilter=(expression:'host.id:1',kind:kuery)"
);
});
describe('when link to logs is clicked', () => {
beforeEach(async () => {
const renderResult = render();
@ -291,6 +357,7 @@ describe('when on the hosts page', () => {
expect(coreStart.application.navigateToApp.mock.calls).toHaveLength(1);
});
});
describe('when showing host Policy Response panel', () => {
let renderResult: ReturnType<typeof render>;
beforeEach(async () => {
@ -305,10 +372,12 @@ describe('when on the hosts page', () => {
dispatchServerReturnedHostPolicyResponse();
});
});
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')
@ -317,17 +386,20 @@ describe('when on the hosts page', () => {
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 show a configuration section for each protection', async () => {
const configAccordions = await renderResult.findAllByTestId(
'hostDetailsPolicyResponseConfigAccordion'
);
expect(configAccordions).not.toBeNull();
});
it('should show an actions section for each configuration', async () => {
const actionAccordions = await renderResult.findAllByTestId(
'hostDetailsPolicyResponseActionsAccordion'
@ -340,6 +412,7 @@ describe('when on the hosts page', () => {
expect(statusHealth).not.toBeNull();
expect(message).not.toBeNull();
});
it('should not show any numbered badges if all actions are successful', () => {
const policyResponse = docGenerator.generatePolicyResponse(
new Date().getTime(),
@ -359,6 +432,7 @@ describe('when on the hosts page', () => {
expect(e).not.toBeNull();
});
});
it('should show a numbered badge if at least one action failed', () => {
reactTestingLibrary.act(() => {
dispatchServerReturnedHostPolicyResponse(HostPolicyResponseActionStatus.failure);
@ -368,6 +442,7 @@ describe('when on the hosts page', () => {
);
expect(attentionBadge).not.toBeNull();
});
it('should show a numbered badge if at least one action has a warning', () => {
reactTestingLibrary.act(() => {
dispatchServerReturnedHostPolicyResponse(HostPolicyResponseActionStatus.warning);
@ -377,6 +452,7 @@ describe('when on the hosts page', () => {
);
expect(attentionBadge).not.toBeNull();
});
it('should include the back to details link', async () => {
const subHeaderBackLink = await renderResult.findByTestId('flyoutSubHeaderBackButton');
expect(subHeaderBackLink.textContent).toBe('Endpoint Details');
@ -384,6 +460,7 @@ describe('when on the hosts page', () => {
'#/management/endpoints?page_index=0&page_size=10&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');

View file

@ -22,7 +22,11 @@ import { createStructuredSelector } from 'reselect';
import { HostDetailsFlyout } from './details';
import * as selectors from '../store/selectors';
import { useHostSelector } from './hooks';
import { HOST_STATUS_TO_HEALTH_COLOR } from './host_constants';
import {
HOST_STATUS_TO_HEALTH_COLOR,
POLICY_STATUS_TO_HEALTH_COLOR,
POLICY_STATUS_TO_TEXT,
} from './host_constants';
import { useNavigateByRouterEventHandler } from '../../../../common/hooks/endpoint/use_navigate_by_router_event_handler';
import { CreateStructuredSelector } from '../../../../common/store';
import { Immutable, HostInfo } from '../../../../../common/endpoint/types';
@ -31,17 +35,18 @@ import { ManagementPageView } from '../../../components/management_page_view';
import { getManagementUrl } from '../../..';
import { FormattedDate } from '../../../../common/components/formatted_date';
const HostLink = memo<{
const HostListNavLink = memo<{
name: string;
href: string;
route: string;
}>(({ name, href, route }) => {
dataTestSubj: string;
}>(({ name, href, route, dataTestSubj }) => {
const clickHandler = useNavigateByRouterEventHandler(route);
return (
// eslint-disable-next-line @elastic/eui/href-or-on-click
<EuiLink
data-test-subj="hostnameCellLink"
data-test-subj={dataTestSubj}
className="eui-textTruncate"
href={href}
onClick={clickHandler}
@ -50,7 +55,7 @@ const HostLink = memo<{
</EuiLink>
);
});
HostLink.displayName = 'HostLink';
HostListNavLink.displayName = 'HostListNavLink';
const selector = (createStructuredSelector as CreateStructuredSelector)(selectors);
export const HostList = () => {
@ -116,7 +121,14 @@ export const HostList = () => {
name: 'endpointDetails',
selected_host: id,
});
return <HostLink name={hostname} href={toRouteUrl} route={toRoutePath} />;
return (
<HostListNavLink
name={hostname}
href={toRouteUrl}
route={toRoutePath}
dataTestSubj="hostnameCellLink"
/>
);
},
},
{
@ -142,41 +154,62 @@ export const HostList = () => {
},
},
{
field: '',
field: 'metadata.endpoint.policy.applied',
name: i18n.translate('xpack.securitySolution.endpointList.policy', {
defaultMessage: 'Policy',
}),
truncateText: true,
// eslint-disable-next-line react/display-name
render: () => {
return <span className="eui-textTruncate">{'Policy Name'}</span>;
},
},
{
field: '',
name: i18n.translate('xpack.securitySolution.endpointList.policyStatus', {
defaultMessage: 'Policy Status',
}),
// eslint-disable-next-line react/display-name
render: () => {
render: (policy: HostInfo['metadata']['endpoint']['policy']['applied']) => {
const toRoutePath = getManagementUrl({
name: 'policyDetails',
policyId: policy.id,
excludePrefix: true,
});
const toRouteUrl = getManagementUrl({
name: 'policyDetails',
policyId: policy.id,
});
return (
<EuiHealth color="success" className="eui-textTruncate">
<FormattedMessage
id="xpack.securitySolution.endpointList.policyStatus"
defaultMessage="Policy Status"
/>
</EuiHealth>
<HostListNavLink
name={policy.name}
href={toRouteUrl}
route={toRoutePath}
dataTestSubj="policyNameCellLink"
/>
);
},
},
{
field: '',
name: i18n.translate('xpack.securitySolution.endpointList.alerts', {
defaultMessage: 'Alerts',
field: 'metadata.endpoint.policy.applied',
name: i18n.translate('xpack.securitySolution.endpointList.policyStatus', {
defaultMessage: 'Policy Status',
}),
dataType: 'number',
render: () => {
return '0';
// eslint-disable-next-line react/display-name
render: (policy: HostInfo['metadata']['endpoint']['policy']['applied'], item: HostInfo) => {
const toRoutePath = getManagementUrl({
name: 'endpointPolicyResponse',
selected_host: item.metadata.host.id,
excludePrefix: true,
});
const toRouteUrl = getManagementUrl({
name: 'endpointPolicyResponse',
selected_host: item.metadata.host.id,
});
return (
<EuiHealth
color={POLICY_STATUS_TO_HEALTH_COLOR[policy.status]}
className="eui-textTruncate"
data-test-subj="rowPolicyStatus"
>
<HostListNavLink
name={POLICY_STATUS_TO_TEXT[policy.status]}
href={toRouteUrl}
route={toRoutePath}
dataTestSubj="policyStatusCellLink"
/>
</EuiHealth>
);
},
},
{