From 1cef65e56fd37452b4ab729d21bfac6779f6daca Mon Sep 17 00:00:00 2001 From: Kevin Logan <56395104+kevinlog@users.noreply.github.com> Date: Wed, 17 Jun 2020 11:27:00 -0400 Subject: [PATCH] [Endpoint] add policy data to Host list UI (#69202) --- .../view/details/host_details.tsx | 38 ++++++++- .../endpoint_hosts/view/host_constants.ts | 15 ++++ .../pages/endpoint_hosts/view/index.test.tsx | 77 +++++++++++++++++ .../pages/endpoint_hosts/view/index.tsx | 83 +++++++++++++------ 4 files changed, 186 insertions(+), 27 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx index b191bfe4effeaf..9ec65a5d17898c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx @@ -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 */} + + {details.endpoint.policy.applied.name} + + + ), }, { 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 [ { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/host_constants.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/host_constants.ts index 645a4896770eee..790bbd3cb98dac 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/host_constants.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/host_constants.ts @@ -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', + }), +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 7d84bb52238a2e..e0f797b1430551 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -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; 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'); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 125723e9bcea66..c67c29fbc73a90 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -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 ); }); -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 ; + return ( + + ); }, }, { @@ -142,43 +154,64 @@ 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 {'Policy Name'}; + 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 ( + + ); }, }, { - field: '', + field: 'metadata.endpoint.policy.applied', 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'], 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 ( - - + ); }, }, - { - field: '', - name: i18n.translate('xpack.securitySolution.endpointList.alerts', { - defaultMessage: 'Alerts', - }), - dataType: 'number', - render: () => { - return '0'; - }, - }, { field: 'metadata.host.os.name', name: i18n.translate('xpack.securitySolution.endpointList.os', {