From 8eceb0db4d4edb0da52564ac082859abfc6ed6e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Tue, 1 Oct 2024 18:54:43 +0200 Subject: [PATCH] [Defend Workflows][Reusable integrations] Handling resuable integrations on endpoint onboarding page (#193518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary During onboarding on the Endpoint list page, if the user already created at least one Elastic Defend integration, we show this screen (original): image Due to the new enterprise level feature added by Fleet team, the reusable integrations, now an integration policy can be assigned **zero, one or more agent policies**, and this PR's goal is to update this onboarding screen to tackle these changes. ### One integration is added to more than one Agent Policies When creating/editing an integration, it can be added to more than one Agent Policies: image #### ✅ Solution for usecase To be able to select where to enroll an Agent, now they are listed as `Package policy - Agent policy` pairs. image ### Some integrations are not added to an Agent Policy image #### ✅ Solution for usecase A new callout is added to indicate to the user that there are some integrations that cannot be deployed to an Agent. image Clicking on the Integrations opens their editing page in a new tab. Clickin on the 'Elastic Defend Integration policies' opens the Defend integration's policies tab in a new browser tab. ### None of the integrations are added to an Agent Policy image #### ✅ Solution for usecase Another 'warning' callout is displayed indicating that there are no usable integrations. This, combined with the other callout hopefully help the user to go forward. image ### RBAC In case the user doesn't hold the required privileges, the same screen is displayed as when there are no hosts and no policies, or there are policies but no hosts. Just as before. image ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) --------- Co-authored-by: Elastic Machine Co-authored-by: Joe Peeples --- .../fleet_package_policy_generator.ts | 2 +- .../common/endpoint/generate_data.ts | 11 +- .../components/management_empty_state.tsx | 307 ++++++++++----- .../pages/endpoint_hosts/store/middleware.ts | 4 - .../pages/endpoint_hosts/view/index.test.tsx | 367 +++++++++++++----- .../pages/endpoint_hosts/view/index.tsx | 230 +++++++---- .../management/services/policies/hooks.ts | 25 +- .../management/services/policies/ingest.ts | 17 +- .../translations/translations/fr-FR.json | 3 +- .../translations/translations/ja-JP.json | 3 +- .../translations/translations/zh-CN.json | 3 +- 11 files changed, 672 insertions(+), 300 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_package_policy_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_package_policy_generator.ts index 215b2ff614379..271718d8e11fd 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_package_policy_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_package_policy_generator.ts @@ -14,7 +14,7 @@ type PartialPackagePolicy = Partial> & { inputs?: PackagePolicy['inputs']; }; -type PartialEndpointPolicyData = Partial> & { +export type PartialEndpointPolicyData = Partial> & { inputs?: PolicyData['inputs']; }; diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index e0811ef8fa821..50ae6b4069770 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -40,6 +40,7 @@ import { import { firstNonNullValue } from './models/ecs_safety_helpers'; import type { EventOptions } from './types/generator'; import { BaseDataGenerator } from './data_generators/base_data_generator'; +import type { PartialEndpointPolicyData } from './data_generators/fleet_package_policy_generator'; import { FleetPackagePolicyGenerator } from './data_generators/fleet_package_policy_generator'; export type Event = AlertEvent | SafeEndpointEvent; @@ -1581,8 +1582,14 @@ export class EndpointDocGenerator extends BaseDataGenerator { /** * Generates a Fleet `package policy` that includes the Endpoint Policy data */ - public generatePolicyPackagePolicy(seed: string = 'seed'): PolicyData { - return new FleetPackagePolicyGenerator(seed).generateEndpointPackagePolicy(); + public generatePolicyPackagePolicy({ + seed, + overrides, + }: { + seed?: string; + overrides?: PartialEndpointPolicyData; + } = {}): PolicyData { + return new FleetPackagePolicyGenerator(seed).generateEndpointPackagePolicy(overrides); } /** diff --git a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx index 94dddba539ce2..2408dad4f39f3 100644 --- a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx +++ b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx @@ -22,12 +22,16 @@ import { EuiLoadingSpinner, EuiLink, EuiSkeletonText, + EuiCallOut, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { INTEGRATIONS_PLUGIN_ID } from '@kbn/fleet-plugin/common'; +import { pagePathGetters } from '@kbn/fleet-plugin/public'; +import type { ImmutableArray, PolicyData } from '../../../common/endpoint/types'; import { useUserPrivileges } from '../../common/components/user_privileges'; import onboardingLogo from '../images/security_administration_onboarding.svg'; -import { useKibana } from '../../common/lib/kibana'; +import { useAppUrl, useKibana } from '../../common/lib/kibana'; const TEXT_ALIGN_CENTER: CSSProperties = Object.freeze({ textAlign: 'center', @@ -103,12 +107,12 @@ const PolicyEmptyState = React.memo<{ {policyEntryPoint ? ( ) : ( )} @@ -170,107 +174,216 @@ const EndpointsEmptyState = React.memo<{ actionDisabled: boolean; handleSelectableOnChange: (o: EuiSelectableProps['options']) => void; selectionOptions: EuiSelectableProps['options']; -}>(({ loading, onActionClick, actionDisabled, handleSelectableOnChange, selectionOptions }) => { - const policySteps = useMemo( - () => [ - { - title: i18n.translate('xpack.securitySolution.endpoint.list.stepOneTitle', { - defaultMessage: 'Select the integration you want to use', - }), - children: ( + policyItems: ImmutableArray; +}>( + ({ + loading, + onActionClick, + actionDisabled, + handleSelectableOnChange, + selectionOptions, + policyItems, + }) => { + const { getAppUrl } = useAppUrl(); + const policyItemsWithoutAgentPolicy = useMemo( + () => policyItems.filter((policy) => !policy.policy_ids.length), + [policyItems] + ); + + const policiesNotAddedToAgentPolicyCallout = useMemo( + () => + !!policyItemsWithoutAgentPolicy.length && ( <> - - - - - + - {(list) => { - return loading ? ( - - - - ) : selectionOptions.length ? ( - list - ) : ( - - ); - }} - + + + + + + + +
    + {policyItemsWithoutAgentPolicy.map((policyItem) => ( +
  • + + {policyItem.name} + +
  • + ))} +
+ + + + + ), + }} + /> +
+ ), - }, - { - title: i18n.translate('xpack.securitySolution.endpoint.list.stepTwoTitle', { - defaultMessage: 'Enroll your agents enabled with Elastic Defend through Fleet', - }), - status: actionDisabled ? 'disabled' : '', - children: ( - - + [getAppUrl, policyItemsWithoutAgentPolicy] + ); + + const policySteps = useMemo( + () => [ + { + title: i18n.translate('xpack.securitySolution.endpoint.list.stepOneTitle', { + defaultMessage: 'Select the integration you want to use', + }), + children: ( + <> - - - + - - - - - ), - }, - ], - [selectionOptions, handleSelectableOnChange, loading, actionDisabled, onActionClick] - ); + {(list) => { + if (loading) { + return ( + + + + ); + } - return ( - - } - bodyComponent={ - - } - /> - ); -}); + if (!selectionOptions.length) { + return ( + + + + ); + } + + return list; + }} + + + {policiesNotAddedToAgentPolicyCallout} + + ), + }, + { + title: i18n.translate('xpack.securitySolution.endpoint.list.stepTwoTitle', { + defaultMessage: 'Enroll your agents enabled with Elastic Defend through Fleet', + }), + status: actionDisabled ? 'disabled' : '', + children: ( + + + + + + + + + + + + + ), + }, + ], + [ + selectionOptions, + loading, + handleSelectableOnChange, + policiesNotAddedToAgentPolicyCallout, + actionDisabled, + onActionClick, + ] + ); + + return ( + + } + bodyComponent={ + + } + /> + ); + } +); const ManagementEmptyState = React.memo<{ loading: boolean; @@ -284,7 +397,11 @@ const ManagementEmptyState = React.memo<{ {loading ? ( - + ) : ( diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index ec27500a45e12..56b92e4692edc 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -156,10 +156,6 @@ const getAgentAndPoliciesForEndpointsList = async ( return; } - // We use the Agent Policy API here, instead of the Package Policy, because we can't use - // filter by ID of the Saved Object. Agent Policy, however, keeps a reference (array) of - // Package Ids that it uses, thus if a reference exists there, then the package policy (policy) - // exists. const policiesFound = ( await sendBulkGetPackagePolicies(http, policyIdsToCheck) ).items.reduce( 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 a5e09dc6d553c..adfc164e98b12 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 @@ -10,6 +10,7 @@ import * as reactTestingLibrary from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { EndpointList } from '.'; import { createUseUiSetting$Mock } from '../../../../common/lib/kibana/kibana_react.mock'; +import type { DeepPartial } from '@kbn/utility-types'; import { mockEndpointDetailsApiResult, @@ -57,6 +58,8 @@ import { getEndpointPrivilegesInitialStateMock } from '../../../../common/compon import { useGetEndpointDetails } from '../../../hooks/endpoint/use_get_endpoint_details'; import { useGetAgentStatus as _useGetAgentStatus } from '../../../hooks/agents/use_get_agent_status'; import { agentStatusMocks } from '../../../../../common/endpoint/service/response_actions/mocks/agent_status.mocks'; +import { useBulkGetAgentPolicies } from '../../../services/policies/hooks'; +import type { PartialEndpointPolicyData } from '../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; const mockUserPrivileges = useUserPrivileges as jest.Mock; // not sure why this can't be imported from '../../../../common/mock/formatted_relative'; @@ -85,6 +88,14 @@ jest.mock('../../../services/policies/ingest', () => { jest.mock('../../../hooks/agents/use_get_agent_status'); const useGetAgentStatusMock = _useGetAgentStatus as jest.Mock; +jest.mock('../../../services/policies/hooks', () => ({ + ...jest.requireActual('../../../services/policies/hooks'), + useBulkGetAgentPolicies: jest.fn().mockReturnValue({}), +})); +const useBulkGetAgentPoliciesMock = useBulkGetAgentPolicies as unknown as jest.Mock< + DeepPartial> +>; + const mockUseUiSetting$ = useUiSetting$ as jest.Mock; const timepickerRanges = [ { @@ -149,6 +160,7 @@ describe('when on the endpoint list page', () => { const { act, screen, fireEvent } = reactTestingLibrary; let render: () => ReturnType; + let renderResult: reactTestingLibrary.RenderResult; let history: AppContextTestRender['history']; let store: AppContextTestRender['store']; let coreStart: AppContextTestRender['coreStart']; @@ -170,7 +182,7 @@ describe('when on the endpoint list page', () => { beforeEach(() => { const mockedContext = createAppRootMockRenderer(); ({ history, store, coreStart, middlewareSpy } = mockedContext); - render = () => mockedContext.render(); + render = () => (renderResult = mockedContext.render()); reactTestingLibrary.act(() => { history.push(`${MANAGEMENT_PATH}/endpoints`); }); @@ -186,9 +198,9 @@ describe('when on the endpoint list page', () => { endpointsResults: [], }); - const renderResult = render(); + render(); const timelineFlyout = renderResult.queryByTestId('timeline-bottom-bar-title-button'); - expect(timelineFlyout).toBeNull(); + expect(timelineFlyout).not.toBeInTheDocument(); }); describe('when there are no endpoints or polices', () => { @@ -199,47 +211,200 @@ describe('when on the endpoint list page', () => { }); it('should show the empty state when there are no hosts or polices', async () => { - const renderResult = render(); + render(); await reactTestingLibrary.act(async () => { await middlewareSpy.waitForAction('serverReturnedPoliciesForOnboarding'); }); // Initially, there are no hosts or policies, so we prompt to add policies first. const table = await renderResult.findByTestId('emptyPolicyTable'); - expect(table).not.toBeNull(); + expect(table).toBeInTheDocument(); }); }); describe('when there are policies, but no hosts', () => { - let renderResult: ReturnType; - beforeEach(async () => { - const policyData = mockPolicyResultList({ total: 3 }).items; + const getOptionsTexts = async () => { + const onboardingPolicySelect = await renderResult.findByTestId('onboardingPolicySelect'); + const options = onboardingPolicySelect.querySelectorAll('[role=option]'); + + return [...options].map(({ textContent }) => textContent); + }; + + const setupPolicyDataMocks = ( + partialPolicyData: PartialEndpointPolicyData[] = [ + { name: 'Package 1', policy_ids: ['policy-1'] }, + ] + ) => { + const policyData = partialPolicyData.map((overrides) => + docGenerator.generatePolicyPackagePolicy({ overrides }) + ); + setEndpointListApiMockImplementation(coreStart.http, { endpointsResults: [], endpointPackagePolicies: policyData, }); + }; - renderResult = render(); - await reactTestingLibrary.act(async () => { - await middlewareSpy.waitForAction('serverReturnedPoliciesForOnboarding'); + beforeEach(async () => { + useBulkGetAgentPoliciesMock.mockReturnValue({ + data: [ + { id: 'policy-1', name: 'Agent Policy 1' }, + { id: 'policy-2', name: 'Agent Policy 2' }, + { id: 'policy-3', name: 'Agent Policy 3' }, + ], + isLoading: false, }); + + setupPolicyDataMocks(); }); + afterEach(() => { jest.clearAllMocks(); }); - it('should show the no hosts empty state', async () => { + it('should show loading spinner while Agent Policies are loading', async () => { + useBulkGetAgentPoliciesMock.mockReturnValue({ isLoading: true }); + render(); + expect( + await renderResult.findByTestId('management-empty-state-loading-spinner') + ).toBeInTheDocument(); + }); + + it('should show the no hosts empty state without loading spinner', async () => { + render(); + + expect( + renderResult.queryByTestId('management-empty-state-loading-spinner') + ).not.toBeInTheDocument(); + const emptyHostsTable = await renderResult.findByTestId('emptyHostsTable'); - expect(emptyHostsTable).not.toBeNull(); + expect(emptyHostsTable).toBeInTheDocument(); }); it('should display the onboarding steps', async () => { + render(); const onboardingSteps = await renderResult.findByTestId('onboardingSteps'); - expect(onboardingSteps).not.toBeNull(); + expect(onboardingSteps).toBeInTheDocument(); }); - it('should show policy selection', async () => { - const onboardingPolicySelect = await renderResult.findByTestId('onboardingPolicySelect'); - expect(onboardingPolicySelect).not.toBeNull(); + describe('policy selection', () => { + it('should show policy selection', async () => { + render(); + const onboardingPolicySelect = await renderResult.findByTestId('onboardingPolicySelect'); + expect(onboardingPolicySelect).toBeInTheDocument(); + }); + + it('should show discrete `package policy - agent policy` pairs', async () => { + setupPolicyDataMocks([ + { name: 'Package 1', policy_ids: ['policy-1'] }, + { name: 'Package 2', policy_ids: ['policy-2'] }, + ]); + + render(); + const optionsTexts = await getOptionsTexts(); + + expect(optionsTexts).toStrictEqual([ + 'Package 1 - Agent Policy 1', + 'Package 2 - Agent Policy 2', + ]); + }); + + it('should display the same package policy with multiple Agent Policies multiple times', async () => { + setupPolicyDataMocks([ + { name: 'Package 1', policy_ids: ['policy-1', 'policy-2', 'policy-3'] }, + ]); + + render(); + const optionsTexts = await getOptionsTexts(); + + expect(optionsTexts).toStrictEqual([ + 'Package 1 - Agent Policy 1', + 'Package 1 - Agent Policy 2', + 'Package 1 - Agent Policy 3', + ]); + }); + + it('should not display a package policy without agent policy', async () => { + setupPolicyDataMocks([ + { name: 'Package 1', policy_ids: [] }, + { name: 'Package 2', policy_ids: ['policy-1'] }, + ]); + + render(); + const optionsTexts = await getOptionsTexts(); + + expect(optionsTexts).toStrictEqual(['Package 2 - Agent Policy 1']); + }); + + it("should fallback to agent policy ID if it's not found", async () => { + setupPolicyDataMocks([{ name: 'Package 1', policy_ids: ['agent-policy-id'] }]); + + render(); + const optionsTexts = await getOptionsTexts(); + expect( + renderResult.queryByTestId('noIntegrationsAddedToAgentPoliciesCallout') + ).not.toBeInTheDocument(); + + expect(optionsTexts).toStrictEqual(['Package 1 - agent-policy-id']); + }); + + it('should show callout indicating that none of the integrations are added to agent policies', async () => { + setupPolicyDataMocks([{ name: 'Package 1', policy_ids: [] }]); + + render(); + + expect( + await renderResult.findByTestId('noIntegrationsAddedToAgentPoliciesCallout') + ).toBeInTheDocument(); + }); + }); + + describe('integration not added to agent policy callout', () => { + it('should not display callout if all integrations are added to agent policies', async () => { + setupPolicyDataMocks([ + { name: 'Package 1', policy_ids: ['policy-1'] }, + { name: 'Package 2', policy_ids: ['policy-2'] }, + ]); + + render(); + await getOptionsTexts(); + + expect( + renderResult.queryByTestId('integrationsNotAddedToAgentPolicyCallout') + ).not.toBeInTheDocument(); + }); + + it('should display callout if an integration is not added to an agent policy', async () => { + setupPolicyDataMocks([ + { name: 'Package 1', policy_ids: ['policy-1'] }, + { name: 'Package 2', policy_ids: [] }, + ]); + + render(); + + expect( + await renderResult.findByTestId('integrationsNotAddedToAgentPolicyCallout') + ).toBeInTheDocument(); + }); + + it('should list all integrations which are not added to an agent policy', async () => { + setupPolicyDataMocks([ + { name: 'Package 1', policy_ids: ['policy-1'] }, + { name: 'Package 2', policy_ids: [] }, + { name: 'Package 3', policy_ids: [] }, + { name: 'Package 4', policy_ids: [] }, + ]); + + render(); + + const integrations = await renderResult.findAllByTestId( + 'integrationWithoutAgentPolicyListItem' + ); + expect(integrations.map(({ textContent }) => textContent)).toStrictEqual([ + 'Package 2', + 'Package 3', + 'Package 4', + ]); + }); }); }); @@ -349,7 +514,7 @@ describe('when on the endpoint list page', () => { }); it('should display rows in the table', async () => { - const renderResult = render(); + render(); await reactTestingLibrary.act(async () => { await middlewareSpy.waitForAction('serverReturnedEndpointList'); }); @@ -357,7 +522,7 @@ describe('when on the endpoint list page', () => { expect(rows).toHaveLength(6); }); it('should show total', async () => { - const renderResult = render(); + render(); await reactTestingLibrary.act(async () => { await middlewareSpy.waitForAction('serverReturnedEndpointList'); }); @@ -365,7 +530,7 @@ describe('when on the endpoint list page', () => { expect(total.textContent).toEqual('Showing 5 endpoints'); }); it('should agent status', async () => { - const renderResult = render(); + render(); await reactTestingLibrary.act(async () => { await middlewareSpy.waitForAction('serverReturnedEndpointList'); }); @@ -380,7 +545,7 @@ describe('when on the endpoint list page', () => { }); it('should display correct policy status', async () => { - const renderResult = render(); + render(); await reactTestingLibrary.act(async () => { await middlewareSpy.waitForAction('serverReturnedEndpointList'); }); @@ -394,12 +559,12 @@ describe('when on the endpoint list page', () => { POLICY_STATUS_TO_HEALTH_COLOR[generatedPolicyStatuses[index]] }]` ) - ).not.toBeNull(); + ).toBeInTheDocument(); }); }); it('should display policy out-of-date warning when changes pending', async () => { - const renderResult = render(); + render(); await reactTestingLibrary.act(async () => { await middlewareSpy.waitForAction('serverReturnedEndpointList'); }); @@ -412,12 +577,12 @@ describe('when on the endpoint list page', () => { }); it('should display policy name as a link', async () => { - const renderResult = render(); + render(); await reactTestingLibrary.act(async () => { await middlewareSpy.waitForAction('serverReturnedEndpointList'); }); const firstPolicyName = (await renderResult.findAllByTestId('policyNameCellLink-link'))[0]; - expect(firstPolicyName).not.toBeNull(); + expect(firstPolicyName).toBeInTheDocument(); expect(firstPolicyName.getAttribute('href')).toEqual( `${APP_PATH}${MANAGEMENT_PATH}/policy/${firstPolicyID}/settings` ); @@ -425,7 +590,6 @@ describe('when on the endpoint list page', () => { describe('when the user clicks the first hostname in the table', () => { const endpointDetails: HostInfo = mockEndpointDetailsApiResult(); - let renderResult: reactTestingLibrary.RenderResult; beforeEach(async () => { mockUseGetEndpointDetails.mockReturnValue({ data: { @@ -447,7 +611,7 @@ describe('when on the endpoint list page', () => { }, }, }); - renderResult = render(); + render(); await reactTestingLibrary.act(async () => { await middlewareSpy.waitForAction('serverReturnedEndpointList'); }); @@ -459,20 +623,20 @@ describe('when on the endpoint list page', () => { it('should show the flyout', async () => { return renderResult.findByTestId('endpointDetailsFlyout').then((flyout) => { - expect(flyout).not.toBeNull(); + expect(flyout).toBeInTheDocument(); }); }); }); it('should show revision number', async () => { - const renderResult = render(); + render(); await reactTestingLibrary.act(async () => { await middlewareSpy.waitForAction('serverReturnedEndpointList'); }); const firstPolicyRevElement = ( await renderResult.findAllByTestId('policyNameCellLink-revision') )[0]; - expect(firstPolicyRevElement).not.toBeNull(); + expect(firstPolicyRevElement).toBeInTheDocument(); expect(firstPolicyRevElement.textContent).toEqual(`rev. ${firstPolicyRev}`); }); }); @@ -502,7 +666,7 @@ describe('when on the endpoint list page', () => { }); it('should update data after some time', async () => { - let renderResult = render(); + render(); await reactTestingLibrary.act(async () => { await middlewareSpy.waitForAction('serverReturnedEndpointList'); }); @@ -518,7 +682,7 @@ describe('when on the endpoint list page', () => { await middlewareSpy.waitForAction('serverReturnedEndpointList'); }); - renderResult = render(); + render(); const updatedTotal = await renderResult.findAllByTestId('endpointListTableTotal'); expect(updatedTotal[0].textContent).toEqual('1 Host'); @@ -601,33 +765,33 @@ describe('when on the endpoint list page', () => { }); it('should show the flyout and footer', async () => { - const renderResult = render(); - expect(renderResult.getByTestId('endpointDetailsFlyout')).not.toBeNull(); - expect(renderResult.getByTestId('endpointDetailsFlyoutFooter')).not.toBeNull(); + render(); + expect(renderResult.getByTestId('endpointDetailsFlyout')).toBeInTheDocument(); + expect(renderResult.getByTestId('endpointDetailsFlyoutFooter')).toBeInTheDocument(); }); it('should display policy name value as a link', async () => { - const renderResult = render(); + render(); const policyDetailsLink = await renderResult.findByTestId('policyNameCellLink-link'); - expect(policyDetailsLink).not.toBeNull(); + expect(policyDetailsLink).toBeInTheDocument(); expect(policyDetailsLink.getAttribute('href')).toEqual( `${APP_PATH}${MANAGEMENT_PATH}/policy/${hostInfo.metadata.Endpoint.policy.applied.id}/settings` ); }); it('should display policy revision number', async () => { - const renderResult = render(); + render(); const policyDetailsRevElement = await renderResult.findByTestId( 'policyNameCellLink-revision' ); - expect(policyDetailsRevElement).not.toBeNull(); + expect(policyDetailsRevElement).toBeInTheDocument(); expect(policyDetailsRevElement.textContent).toEqual( `rev. ${hostInfo.metadata.Endpoint.policy.applied.endpoint_policy_version}` ); }); it('should update the URL when policy name link is clicked', async () => { - const renderResult = render(); + render(); const policyDetailsLink = await renderResult.findByTestId('policyNameCellLink-link'); const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl'); reactTestingLibrary.act(() => { @@ -640,7 +804,7 @@ describe('when on the endpoint list page', () => { }); it('should update the URL when policy status link is clicked', async () => { - const renderResult = render(); + render(); const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl'); reactTestingLibrary.act(() => { @@ -654,7 +818,7 @@ describe('when on the endpoint list page', () => { it('should display Success overall policy status', async () => { getMockUseEndpointDetails(HostPolicyResponseActionStatus.success); - const renderResult = render(); + render(); const policyStatusBadge = await renderResult.findByTestId('policyStatusValue'); expect(renderResult.getByTestId('policyStatusValue-success')).toBeTruthy(); expect(policyStatusBadge.textContent).toEqual('Success'); @@ -662,7 +826,7 @@ describe('when on the endpoint list page', () => { it('should display Warning overall policy status', async () => { getMockUseEndpointDetails(HostPolicyResponseActionStatus.warning); - const renderResult = render(); + render(); const policyStatusBadge = await renderResult.findByTestId('policyStatusValue'); expect(policyStatusBadge.textContent).toEqual('Warning'); expect(renderResult.getByTestId('policyStatusValue-warning')).toBeTruthy(); @@ -670,7 +834,7 @@ describe('when on the endpoint list page', () => { it('should display Failed overall policy status', async () => { getMockUseEndpointDetails(HostPolicyResponseActionStatus.failure); - const renderResult = render(); + render(); const policyStatusBadge = await renderResult.findByTestId('policyStatusValue'); expect(policyStatusBadge.textContent).toEqual('Failed'); expect(renderResult.getByTestId('policyStatusValue-failure')).toBeTruthy(); @@ -678,15 +842,15 @@ describe('when on the endpoint list page', () => { it('should display Unknown overall policy status', async () => { getMockUseEndpointDetails('' as HostPolicyResponseActionStatus); - const renderResult = render(); + render(); const policyStatusBadge = await renderResult.findByTestId('policyStatusValue'); expect(policyStatusBadge.textContent).toEqual('Unknown'); expect(renderResult.getByTestId('policyStatusValue-')).toBeTruthy(); }); it('should show the Take Action button', async () => { - const renderResult = render(); - expect(renderResult.getByTestId('endpointDetailsActionsButton')).not.toBeNull(); + render(); + expect(renderResult.getByTestId('endpointDetailsActionsButton')).toBeInTheDocument(); }); describe('Activity Log tab', () => { @@ -705,8 +869,8 @@ describe('when on the endpoint list page', () => { }); describe('when `canReadActionsLogManagement` is TRUE', () => { - it('should start with the activity log tab as unselected', async () => { - const renderResult = await render(); + it('should start with the activity log tab as unselected', () => { + render(); const detailsTab = renderResult.getByTestId('endpoint-details-flyout-tab-details'); const activityLogTab = renderResult.getByTestId( 'endpoint-details-flyout-tab-activity_log' @@ -714,12 +878,14 @@ describe('when on the endpoint list page', () => { expect(detailsTab).toHaveAttribute('aria-selected', 'true'); expect(activityLogTab).toHaveAttribute('aria-selected', 'false'); - expect(renderResult.getByTestId('endpointDetailsFlyoutBody')).not.toBeNull(); - expect(renderResult.queryByTestId('endpointActivityLogFlyoutBody')).toBeNull(); + expect(renderResult.getByTestId('endpointDetailsFlyoutBody')).toBeInTheDocument(); + expect( + renderResult.queryByTestId('endpointActivityLogFlyoutBody') + ).not.toBeInTheDocument(); }); it('should show the activity log content when selected', async () => { - const renderResult = await render(); + render(); const detailsTab = renderResult.getByTestId('endpoint-details-flyout-tab-details'); const activityLogTab = renderResult.getByTestId( 'endpoint-details-flyout-tab-activity_log' @@ -728,13 +894,13 @@ describe('when on the endpoint list page', () => { await userEvent.click(activityLogTab); expect(detailsTab).toHaveAttribute('aria-selected', 'false'); expect(activityLogTab).toHaveAttribute('aria-selected', 'true'); - expect(renderResult.getByTestId('endpointActivityLogFlyoutBody')).not.toBeNull(); - expect(renderResult.queryByTestId('endpointDetailsFlyoutBody')).toBeNull(); + expect(renderResult.getByTestId('endpointActivityLogFlyoutBody')).toBeInTheDocument(); + expect(renderResult.queryByTestId('endpointDetailsFlyoutBody')).not.toBeInTheDocument(); }); }); describe('when `canReadActionsLogManagement` is FALSE', () => { - it('should not show the response actions history tab', async () => { + it('should not show the response actions history tab', () => { mockUserPrivileges.mockReturnValue({ ...mockInitialUserPrivilegesState(), endpointPrivileges: { @@ -744,15 +910,15 @@ describe('when on the endpoint list page', () => { canAccessFleet: true, }, }); - const renderResult = await render(); + render(); const detailsTab = renderResult.getByTestId('endpoint-details-flyout-tab-details'); const activityLogTab = renderResult.queryByTestId( 'endpoint-details-flyout-tab-activity_log' ); expect(detailsTab).toHaveAttribute('aria-selected', 'true'); - expect(activityLogTab).toBeNull(); - expect(renderResult.findByTestId('endpointDetailsFlyoutBody')).not.toBeNull(); + expect(activityLogTab).not.toBeInTheDocument(); + expect(renderResult.getByTestId('endpointDetailsFlyoutBody')).toBeInTheDocument(); }); it('should show the overview tab when force loading actions history tab via URL', async () => { @@ -769,7 +935,7 @@ describe('when on the endpoint list page', () => { history.push(`${MANAGEMENT_PATH}/endpoints?selected_endpoint=1&show=activity_log`); }); - const renderResult = await render(); + render(); await middlewareSpy.waitForAction('serverFinishedInitialization'); const detailsTab = renderResult.getByTestId('endpoint-details-flyout-tab-details'); @@ -778,14 +944,13 @@ describe('when on the endpoint list page', () => { ); expect(detailsTab).toHaveAttribute('aria-selected', 'true'); - expect(activityLogTab).toBeNull(); - expect(renderResult.findByTestId('endpointDetailsFlyoutBody')).not.toBeNull(); + expect(activityLogTab).not.toBeInTheDocument(); + expect(renderResult.getByTestId('endpointDetailsFlyoutBody')).toBeInTheDocument(); }); }); }); describe('when showing host Policy Response panel', () => { - let renderResult: ReturnType; beforeEach(async () => { coreStart.http.post.mockImplementation(async (requestOptions) => { if (requestOptions.path === HOST_METADATA_LIST_ROUTE) { @@ -793,7 +958,7 @@ describe('when on the endpoint list page', () => { } throw new Error(`POST to '${requestOptions.path}' does not have a mock response!`); }); - renderResult = await render(); + render(); const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl'); reactTestingLibrary.act(() => { @@ -806,14 +971,14 @@ describe('when on the endpoint list page', () => { it('should hide the host details panel', async () => { const endpointDetailsFlyout = renderResult.queryByTestId('endpointDetailsFlyoutBody'); - expect(endpointDetailsFlyout).toBeNull(); + expect(endpointDetailsFlyout).not.toBeInTheDocument(); }); it('should display policy response sub-panel', async () => { - expect(await renderResult.findByTestId('flyoutSubHeaderBackButton')).not.toBeNull(); + expect(await renderResult.findByTestId('flyoutSubHeaderBackButton')).toBeInTheDocument(); expect( await renderResult.findByTestId('endpointDetailsPolicyResponseFlyoutBody') - ).not.toBeNull(); + ).toBeInTheDocument(); }); it('should include the back to details link', async () => { @@ -862,14 +1027,13 @@ describe('when on the endpoint list page', () => { }; let isolateApiMock: ReturnType; - let renderResult: ReturnType; beforeEach(async () => { getKibanaServicesMock.mockReturnValue(coreStart); reactTestingLibrary.act(() => { history.push(`${MANAGEMENT_PATH}/endpoints?selected_endpoint=1&show=isolate`); }); - renderResult = render(); + render(); await middlewareSpy.waitForAction('serverFinishedInitialization'); // Need to reset `http.post` and adjust it so that the mock for http host @@ -880,7 +1044,7 @@ describe('when on the endpoint list page', () => { }); it('should show the isolate form', () => { - expect(renderResult.getByTestId('host_isolation_comment')).not.toBeNull(); + expect(renderResult.getByTestId('host_isolation_comment')).toBeInTheDocument(); }); it('should take you back to details when back link below the flyout header is clicked', async () => { @@ -922,7 +1086,7 @@ describe('when on the endpoint list page', () => { it('should isolate endpoint host when confirm is clicked', async () => { await confirmIsolateAndWaitForApiResponse(); - expect(renderResult.getByTestId('hostIsolateSuccessMessage')).not.toBeNull(); + expect(renderResult.getByTestId('hostIsolateSuccessMessage')).toBeInTheDocument(); }); it('should navigate to details when the Complete button on success message is clicked', async () => { @@ -946,7 +1110,7 @@ describe('when on the endpoint list page', () => { }); await confirmIsolateAndWaitForApiResponse('failure'); - expect(renderResult.getByText('oh oh. something went wrong')).not.toBeNull(); + expect(renderResult.getByText('oh oh. something went wrong')).toBeInTheDocument(); }); it('should reset isolation state and show form again', async () => { @@ -954,7 +1118,7 @@ describe('when on the endpoint list page', () => { // (`show` is NOT `isolate`), then the state should be reset so that the form show up again the next // time `isolate host` is clicked await confirmIsolateAndWaitForApiResponse(); - expect(renderResult.getByTestId('hostIsolateSuccessMessage')).not.toBeNull(); + expect(renderResult.getByTestId('hostIsolateSuccessMessage')).toBeInTheDocument(); // Close flyout const changeUrlAction = middlewareSpy.waitForAction('userChangedUrl'); @@ -975,7 +1139,7 @@ describe('when on the endpoint list page', () => { }); it('should NOT show the flyout footer', () => { - expect(renderResult.queryByTestId('endpointDetailsFlyoutFooter')).toBeNull(); + expect(renderResult.queryByTestId('endpointDetailsFlyoutFooter')).not.toBeInTheDocument(); }); }); }); @@ -985,7 +1149,6 @@ describe('when on the endpoint list page', () => { let hostInfo: HostInfo[]; let agentId: string; let agentPolicyId: string; - let renderResult: ReturnType; let endpointActionsButton: HTMLElement; // 2nd endpoint only has isolation capabilities @@ -1069,7 +1232,7 @@ describe('when on the endpoint list page', () => { history.push(`${MANAGEMENT_PATH}/endpoints`); }); - renderResult = render(); + render(); await middlewareSpy.waitForAction('serverReturnedEndpointList'); await middlewareSpy.waitForAction('serverReturnedEndpointAgentPolicies'); @@ -1130,7 +1293,7 @@ describe('when on the endpoint list page', () => { reactTestingLibrary.fireEvent.click(endpointActionsButton); }); const isolateLink = screen.queryByTestId('isolateLink'); - expect(isolateLink).toBeNull(); + expect(isolateLink).not.toBeInTheDocument(); }); it('navigates to the Security Solution Host Details page', async () => { @@ -1179,7 +1342,7 @@ describe('when on the endpoint list page', () => { }); render(); const banner = screen.queryByTestId('callout-endpoints-list-transform-failed'); - expect(banner).toBeNull(); + expect(banner).not.toBeInTheDocument(); }); it('is not displayed when non-relevant transform is failing', () => { @@ -1193,7 +1356,7 @@ describe('when on the endpoint list page', () => { }); render(); const banner = screen.queryByTestId('callout-endpoints-list-transform-failed'); - expect(banner).toBeNull(); + expect(banner).not.toBeInTheDocument(); }); it('is not displayed when no endpoint policy', () => { @@ -1207,7 +1370,7 @@ describe('when on the endpoint list page', () => { }); render(); const banner = screen.queryByTestId('callout-endpoints-list-transform-failed'); - expect(banner).toBeNull(); + expect(banner).not.toBeInTheDocument(); }); it('is displayed when relevant transform state is failed state', async () => { @@ -1268,12 +1431,12 @@ describe('when on the endpoint list page', () => { canAccessFleet: true, }), }); - const renderResult = render(); + render(); await reactTestingLibrary.act(async () => { await middlewareSpy.waitForAction('serverReturnedPoliciesForOnboarding'); }); const onboardingSteps = await renderResult.findByTestId('onboardingSteps'); - expect(onboardingSteps).not.toBeNull(); + expect(onboardingSteps).toBeInTheDocument(); }); it('user has endpoint list READ and fleet All and can view entire onboarding screen', async () => { mockUserPrivileges.mockReturnValue({ @@ -1283,12 +1446,12 @@ describe('when on the endpoint list page', () => { canAccessFleet: true, }), }); - const renderResult = render(); + render(); await reactTestingLibrary.act(async () => { await middlewareSpy.waitForAction('serverReturnedPoliciesForOnboarding'); }); const onboardingSteps = await renderResult.findByTestId('onboardingSteps'); - expect(onboardingSteps).not.toBeNull(); + expect(onboardingSteps).toBeInTheDocument(); }); it('user has endpoint list ALL/READ and fleet NONE and can view a modified onboarding screen with no actions link to fleet', async () => { mockUserPrivileges.mockReturnValue({ @@ -1298,28 +1461,26 @@ describe('when on the endpoint list page', () => { canAccessFleet: false, }), }); - const renderResult = render(); + render(); await reactTestingLibrary.act(async () => { await middlewareSpy.waitForAction('serverReturnedPoliciesForOnboarding'); }); const onboardingSteps = await renderResult.findByTestId('policyOnboardingInstructions'); - expect(onboardingSteps).not.toBeNull(); + expect(onboardingSteps).toBeInTheDocument(); const noPrivilegesPage = await renderResult.findByTestId('noFleetAccess'); - expect(noPrivilegesPage).not.toBeNull(); + expect(noPrivilegesPage).toBeInTheDocument(); const startButton = renderResult.queryByTestId('onboardingStartButton'); - expect(startButton).toBeNull(); + expect(startButton).not.toBeInTheDocument(); }); }); describe('endpoint list take action with RBAC controls', () => { - let renderResult: ReturnType; - const renderAndClickActionsButton = async (tableRow: number = 0) => { reactTestingLibrary.act(() => { history.push(`${MANAGEMENT_PATH}/endpoints`); }); - renderResult = render(); + render(); await middlewareSpy.waitForAction('serverReturnedEndpointList'); await middlewareSpy.waitForAction('serverReturnedEndpointAgentPolicies'); @@ -1408,7 +1569,7 @@ describe('when on the endpoint list page', () => { }); await renderAndClickActionsButton(); const isolateLink = await renderResult.findByTestId('isolateLink'); - expect(isolateLink).not.toBeNull(); + expect(isolateLink).toBeInTheDocument(); }); it('hides Isolate host option if canIsolateHost is NONE', async () => { mockUserPrivileges.mockReturnValue({ @@ -1420,7 +1581,7 @@ describe('when on the endpoint list page', () => { }); await renderAndClickActionsButton(); const isolateLink = screen.queryByTestId('isolateLink'); - expect(isolateLink).toBeNull(); + expect(isolateLink).not.toBeInTheDocument(); }); it('shows unisolate host option if canUnHostIsolate is READ/ALL', async () => { mockUserPrivileges.mockReturnValue({ @@ -1432,7 +1593,7 @@ describe('when on the endpoint list page', () => { }); await renderAndClickActionsButton(1); const unisolateLink = await renderResult.findByTestId('unIsolateLink'); - expect(unisolateLink).not.toBeNull(); + expect(unisolateLink).toBeInTheDocument(); }); it('hides unisolate host option if canUnIsolateHost is NONE', async () => { mockUserPrivileges.mockReturnValue({ @@ -1444,7 +1605,7 @@ describe('when on the endpoint list page', () => { }); await renderAndClickActionsButton(1); const unisolateLink = renderResult.queryByTestId('unIsolateLink'); - expect(unisolateLink).toBeNull(); + expect(unisolateLink).not.toBeInTheDocument(); }); it('shows the Responder option when at least one rbac privilege from host isolation, process operation and file operation, is set to TRUE', async () => { @@ -1457,7 +1618,7 @@ describe('when on the endpoint list page', () => { }); await renderAndClickActionsButton(); const responderButton = await renderResult.findByTestId('console'); - expect(responderButton).not.toBeNull(); + expect(responderButton).toBeInTheDocument(); }); it('hides the Responder option when host isolation, process operation and file operations are ALL set to NONE', async () => { @@ -1470,13 +1631,13 @@ describe('when on the endpoint list page', () => { }); await renderAndClickActionsButton(); const responderButton = renderResult.queryByTestId('console'); - expect(responderButton).toBeNull(); + expect(responderButton).not.toBeInTheDocument(); }); it('always shows the Host details link', async () => { mockUserPrivileges.mockReturnValue(getUserPrivilegesMockDefaultValue()); await renderAndClickActionsButton(); const hostLink = await renderResult.findByTestId('hostLink'); - expect(hostLink).not.toBeNull(); + expect(hostLink).toBeInTheDocument(); }); it('shows Agent Policy, View Agent Details and Reassign Policy Links when canReadFleetAgents,canWriteFleetAgents,canReadFleetAgentPolicies RBAC control is enabled', async () => { mockUserPrivileges.mockReturnValue({ @@ -1493,9 +1654,9 @@ describe('when on the endpoint list page', () => { const agentPolicyLink = await renderResult.findByTestId('agentPolicyLink'); const agentDetailsLink = await renderResult.findByTestId('agentDetailsLink'); const agentPolicyReassignLink = await renderResult.findByTestId('agentPolicyReassignLink'); - expect(agentPolicyLink).not.toBeNull(); - expect(agentDetailsLink).not.toBeNull(); - expect(agentPolicyReassignLink).not.toBeNull(); + expect(agentPolicyLink).toBeInTheDocument(); + expect(agentDetailsLink).toBeInTheDocument(); + expect(agentPolicyReassignLink).toBeInTheDocument(); }); it('hides Agent Policy, View Agent Details and Reassign Policy Links when canAccessFleet RBAC control is NOT enabled', async () => { mockUserPrivileges.mockReturnValue({ @@ -1509,9 +1670,9 @@ describe('when on the endpoint list page', () => { const agentPolicyLink = renderResult.queryByTestId('agentPolicyLink'); const agentDetailsLink = renderResult.queryByTestId('agentDetailsLink'); const agentPolicyReassignLink = renderResult.queryByTestId('agentPolicyReassignLink'); - expect(agentPolicyLink).toBeNull(); - expect(agentDetailsLink).toBeNull(); - expect(agentPolicyReassignLink).toBeNull(); + expect(agentPolicyLink).not.toBeInTheDocument(); + expect(agentDetailsLink).not.toBeInTheDocument(); + expect(agentPolicyReassignLink).not.toBeInTheDocument(); }); }); }); 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 2ed2b20ab78a7..162d05f54ec21 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 @@ -40,7 +40,7 @@ import { EndpointListNavLink } from './components/endpoint_list_nav_link'; import { AgentStatus } from '../../../../common/components/endpoint/agents/agent_status'; import { EndpointDetailsFlyout } from './details'; import * as selectors from '../store/selectors'; -import { nonExistingPolicies } from '../store/selectors'; +import type { nonExistingPolicies } from '../store/selectors'; import { useEndpointSelector } from './hooks'; import { POLICY_STATUS_TO_HEALTH_COLOR, POLICY_STATUS_TO_TEXT } from './host_constants'; import type { CreateStructuredSelector } from '../../../../common/store'; @@ -69,6 +69,7 @@ import { APP_UI_ID } from '../../../../../common/constants'; import { ManagementEmptyStateWrapper } from '../../../components/management_empty_state_wrapper'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; import { BackToPolicyListButton } from './components/back_to_policy_list_button'; +import { useBulkGetAgentPolicies } from '../../../services/policies/hooks'; const MAX_PAGINATED_ITEM = 9999; @@ -338,8 +339,8 @@ export const EndpointList = () => { patternsError, metadataTransformStats, isInitialized, + nonExistingPolicies: missingPolicies, } = useEndpointSelector(selector); - const missingPolicies = useEndpointSelector(nonExistingPolicies); const { canReadEndpointList, canAccessFleet, @@ -353,24 +354,22 @@ export const EndpointList = () => { // cap ability to page at 10k records. (max_result_window) const maxPageCount = totalItemCount > MAX_PAGINATED_ITEM ? MAX_PAGINATED_ITEM : totalItemCount; - const hasPolicyData = useMemo(() => policyItems && policyItems.length > 0, [policyItems]); - const hasListData = useMemo(() => listData && listData.length > 0, [listData]); + const hasPolicyData = policyItems && policyItems.length > 0; + const hasListData = listData && listData.length > 0; const refreshStyle = useMemo(() => { return { display: endpointsExist ? 'flex' : 'none', maxWidth: 200 }; }, [endpointsExist]); - const refreshIsPaused = useMemo(() => { - return !endpointsExist ? false : hasSelectedEndpoint ? true : !isAutoRefreshEnabled; - }, [endpointsExist, hasSelectedEndpoint, isAutoRefreshEnabled]); + const refreshIsPaused = !endpointsExist + ? false + : hasSelectedEndpoint + ? true + : !isAutoRefreshEnabled; - const refreshInterval = useMemo(() => { - return !endpointsExist ? DEFAULT_POLL_INTERVAL : autoRefreshInterval; - }, [endpointsExist, autoRefreshInterval]); + const refreshInterval = !endpointsExist ? DEFAULT_POLL_INTERVAL : autoRefreshInterval; - const shouldShowKQLBar = useMemo(() => { - return endpointsExist && !patternsError; - }, [endpointsExist, patternsError]); + const shouldShowKQLBar = endpointsExist && !patternsError; const paginationSetup = useMemo(() => { return { @@ -465,6 +464,57 @@ export const EndpointList = () => { [dispatch] ); + const stateToDisplay: + | 'loading' + | 'policyEmptyState' + | 'policyEmptyStateWithoutFleetAccess' + | 'hostsEmptyState' + | 'endpointTable' + | 'listError' = useMemo(() => { + if (!isInitialized) { + return 'loading'; + } else if (listError) { + return 'listError'; + } else if (endpointsExist) { + return 'endpointTable'; + } else if (canReadEndpointList && !canAccessFleet) { + return 'policyEmptyStateWithoutFleetAccess'; + } else if (!policyItemsLoading && hasPolicyData) { + return 'hostsEmptyState'; + } else { + return 'policyEmptyState'; + } + }, [ + canAccessFleet, + canReadEndpointList, + endpointsExist, + hasPolicyData, + isInitialized, + listError, + policyItemsLoading, + ]); + + const referencedAgentPolicyIds: string[] = useMemo( + // Agent Policy IDs should be unique as one Agent Policy can have only one Defend integration + () => policyItems.flatMap((item) => item.policy_ids), + [policyItems] + ); + + const { data: referencedAgentPolicies, isLoading: isAgentPolicesLoading } = + useBulkGetAgentPolicies({ + isEnabled: stateToDisplay === 'hostsEmptyState', + policyIds: referencedAgentPolicyIds, + }); + + const agentPolicyNameMap = useMemo( + () => + referencedAgentPolicies?.reduce>((acc, policy) => { + acc[policy.id] = policy.name; + return acc; + }, {}) ?? {}, + [referencedAgentPolicies] + ); + // Used for an auto-refresh super date picker version without any date/time selection const onTimeChange = useCallback(() => {}, []); @@ -526,86 +576,92 @@ export const EndpointList = () => { ); const mutableListData = useMemo(() => [...listData], [listData]); + const renderTableOrEmptyState = useMemo(() => { - if (!isInitialized) { - return ( - - } - title={ -

- {i18n.translate('xpack.securitySolution.endpoint.list.loadingEndpointManagement', { - defaultMessage: 'Loading Endpoint Management', - })} -

- } + switch (stateToDisplay) { + case 'loading': + return ( + + } + title={ +

+ {i18n.translate( + 'xpack.securitySolution.endpoint.list.loadingEndpointManagement', + { + defaultMessage: 'Loading Endpoint Management', + } + )} +

+ } + /> +
+ ); + case 'listError': + return ( + + {listError?.error}} + body={

{listError?.message}

} + /> +
+ ); + case 'endpointTable': + return ( + -
- ); - } else if (listError) { - return ( - - {listError.error}} - body={

{listError.message}

} + ); + case 'policyEmptyStateWithoutFleetAccess': + return ( + + + + ); + case 'hostsEmptyState': + const selectionOptions: EuiSelectableProps['options'] = policyItems.flatMap((policy) => + // displaying Package Policy - Agent Policy pairs + policy.policy_ids.map((agentPolicyId) => ({ + key: agentPolicyId, + label: `${policy.name} - ${agentPolicyNameMap[agentPolicyId] || agentPolicyId}`, + checked: selectedPolicyId === agentPolicyId ? 'on' : undefined, + })) + ); + + return ( + -
- ); - } else if (endpointsExist) { - return ( - - ); - } else if (canReadEndpointList && !canAccessFleet) { - return ( - - - - ); - } else if (!policyItemsLoading && hasPolicyData) { - const selectionOptions: EuiSelectableProps['options'] = policyItems - .filter((item) => item.policy_id) - .map((item) => { - return { - key: item.policy_id as string, - label: item.name, - checked: selectedPolicyId === item.policy_id ? 'on' : undefined, - }; - }); - return ( - - ); - } else { - return ( - - - - ); + ); + case 'policyEmptyState': + default: + return ( + + + + ); } }, [ - isInitialized, + stateToDisplay, listError, - endpointsExist, - canReadEndpointList, - canAccessFleet, policyItemsLoading, - hasPolicyData, mutableListData, columns, paginationSetup, @@ -615,6 +671,8 @@ export const EndpointList = () => { sorting, endpointPrivilegesLoading, policyItems, + agentPolicyNameMap, + isAgentPolicesLoading, handleDeployEndpointsClick, selectedPolicyId, handleSelectableOnChange, diff --git a/x-pack/plugins/security_solution/public/management/services/policies/hooks.ts b/x-pack/plugins/security_solution/public/management/services/policies/hooks.ts index 34ccf5677d144..0e823c985c696 100644 --- a/x-pack/plugins/security_solution/public/management/services/policies/hooks.ts +++ b/x-pack/plugins/security_solution/public/management/services/policies/hooks.ts @@ -7,13 +7,14 @@ import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query'; import type { IHttpFetchError } from '@kbn/core-http-browser'; -import type { GetInfoResponse } from '@kbn/fleet-plugin/common'; +import type { BulkGetAgentPoliciesResponse } from '@kbn/fleet-plugin/common'; +import { type GetInfoResponse } from '@kbn/fleet-plugin/common'; import { firstValueFrom } from 'rxjs'; import type { IKibanaSearchResponse } from '@kbn/search-types'; import { ENDPOINT_PACKAGE_POLICIES_STATS_STRATEGY } from '../../../../common/endpoint/constants'; import { useHttp, useKibana } from '../../../common/lib/kibana'; import { MANAGEMENT_DEFAULT_PAGE_SIZE } from '../../common/constants'; -import { sendGetEndpointSecurityPackage } from './ingest'; +import { sendBulkGetAgentPolicies, sendGetEndpointSecurityPackage } from './ingest'; import type { GetPolicyListResponse } from '../../pages/policy/types'; import { sendGetEndpointSpecificPackagePolicies } from './policies'; import type { ServerApiError } from '../../../common/types'; @@ -83,3 +84,23 @@ export function useGetEndpointSecurityPackage({ customQueryOptions ); } + +export function useBulkGetAgentPolicies({ + isEnabled, + policyIds, +}: { + isEnabled: boolean; + policyIds: string[]; +}): QueryObserverResult { + const http = useHttp(); + + return useQuery( + ['agentPolicies', policyIds], + + async () => { + return (await sendBulkGetAgentPolicies({ http, requestBody: { ids: policyIds } }))?.items; + }, + + { enabled: isEnabled, refetchOnWindowFocus: false, retry: 1 } + ); +} diff --git a/x-pack/plugins/security_solution/public/management/services/policies/ingest.ts b/x-pack/plugins/security_solution/public/management/services/policies/ingest.ts index c125464bffdb9..2437e3d267a11 100644 --- a/x-pack/plugins/security_solution/public/management/services/policies/ingest.ts +++ b/x-pack/plugins/security_solution/public/management/services/policies/ingest.ts @@ -10,9 +10,12 @@ import type { GetAgentStatusResponse, GetPackagePoliciesResponse, GetInfoResponse, + BulkGetAgentPoliciesResponse, } from '@kbn/fleet-plugin/common'; -import { epmRouteService, API_VERSIONS } from '@kbn/fleet-plugin/common'; +import { epmRouteService, API_VERSIONS, agentPolicyRouteService } from '@kbn/fleet-plugin/common'; +import type { BulkGetAgentPoliciesRequestSchema } from '@kbn/fleet-plugin/server/types'; +import type { TypeOf } from '@kbn/config-schema'; import type { NewPolicyData } from '../../../../common/endpoint/types'; import type { GetPolicyResponse, UpdatePolicyResponse } from '../../pages/policy/types'; @@ -120,3 +123,15 @@ export const sendGetEndpointSecurityPackage = async ( } return endpointPackageInfo; }; + +export const sendBulkGetAgentPolicies = async ({ + http, + requestBody, +}: { + http: HttpStart; + requestBody: TypeOf; +}): Promise => + http.post(agentPolicyRouteService.getBulkGetPath(), { + version: API_VERSIONS.public.v1, + body: JSON.stringify(requestBody), + }); diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index c07189b1669d8..67324e7024bfc 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -37775,7 +37775,6 @@ "xpack.securitySolution.endpoint.list.loadingPolicies": "Chargement des intégrations", "xpack.securitySolution.endpoint.list.noEndpointsInstructions": "Vous avez ajouté l'intégration Elastic Defend. Vous pouvez maintenant enregistrer vos agents en suivant la procédure ci-dessous.", "xpack.securitySolution.endpoint.list.noEndpointsPrompt": "Étape suivante : Enregistrer un agent avec Elastic Defend", - "xpack.securitySolution.endpoint.list.noPolicies": "Il n'existe aucune intégration.", "xpack.securitySolution.endpoint.list.os": "Système d'exploitation", "xpack.securitySolution.endpoint.list.pageSubTitle": "Hôtes exécutant Elastic Defend", "xpack.securitySolution.endpoint.list.pageTitle": "Points de terminaison", @@ -47699,4 +47698,4 @@ "xpack.watcher.watchEdit.thresholdWatchExpression.aggType.fieldIsRequiredValidationMessage": "Ce champ est requis.", "xpack.watcher.watcherDescription": "Détectez les modifications survenant dans vos données en créant, gérant et monitorant des alertes." } -} +} \ No newline at end of file diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index bc63fd4be919f..ae40b40dd5573 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -37517,7 +37517,6 @@ "xpack.securitySolution.endpoint.list.loadingPolicies": "統合を読み込んでいます", "xpack.securitySolution.endpoint.list.noEndpointsInstructions": "Elastic Defend統合を追加しました。次の手順を使用して、エージェントを登録してください。", "xpack.securitySolution.endpoint.list.noEndpointsPrompt": "次のステップ:Elastic Defendにエージェントを登録する", - "xpack.securitySolution.endpoint.list.noPolicies": "統合はありません。", "xpack.securitySolution.endpoint.list.os": "OS", "xpack.securitySolution.endpoint.list.pageSubTitle": "Elastic Defendを実行しているホスト", "xpack.securitySolution.endpoint.list.pageTitle": "エンドポイント", @@ -47436,4 +47435,4 @@ "xpack.watcher.watchEdit.thresholdWatchExpression.aggType.fieldIsRequiredValidationMessage": "フィールドを選択してください。", "xpack.watcher.watcherDescription": "アラートの作成、管理、監視によりデータへの変更を検知します。" } -} +} \ No newline at end of file diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 10a7f0e566fef..130727459f54d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -37563,7 +37563,6 @@ "xpack.securitySolution.endpoint.list.loadingPolicies": "正在加载集成", "xpack.securitySolution.endpoint.list.noEndpointsInstructions": "您已添加 Elastic Defend 集成。现在,按照以下步骤注册您的代理。", "xpack.securitySolution.endpoint.list.noEndpointsPrompt": "下一步:将代理注册到 Elastic Defend", - "xpack.securitySolution.endpoint.list.noPolicies": "没有集成。", "xpack.securitySolution.endpoint.list.os": "OS", "xpack.securitySolution.endpoint.list.pageSubTitle": "运行 Elastic Defend 的主机", "xpack.securitySolution.endpoint.list.pageTitle": "终端", @@ -47490,4 +47489,4 @@ "xpack.watcher.watchEdit.thresholdWatchExpression.aggType.fieldIsRequiredValidationMessage": "此字段必填。", "xpack.watcher.watcherDescription": "通过创建、管理和监测警报来检测数据中的更改。" } -} +} \ No newline at end of file