diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_multi_select.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_multi_select.tsx index 4b10b2e2fc9ac..63d49ab4dffe5 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_multi_select.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_multi_select.tsx @@ -9,10 +9,11 @@ import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { EuiComboBox } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { uniq } from 'lodash'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; -import type { PackageInfo } from '../../../../../../../../../common'; +import type { AgentPolicy, PackageInfo } from '../../../../../../../../../common'; export interface Props { isLoading: boolean; @@ -20,6 +21,7 @@ export interface Props { selectedPolicyIds: string[]; setSelectedPolicyIds: (policyIds: string[]) => void; packageInfo?: PackageInfo; + selectedAgentPolicies: AgentPolicy[]; } export const AgentPolicyMultiSelect: React.FunctionComponent = ({ @@ -27,11 +29,25 @@ export const AgentPolicyMultiSelect: React.FunctionComponent = ({ agentPolicyMultiOptions, selectedPolicyIds, setSelectedPolicyIds, + selectedAgentPolicies, }) => { const selectedOptions = useMemo(() => { return agentPolicyMultiOptions.filter((option) => selectedPolicyIds.includes(option.key!)); }, [agentPolicyMultiOptions, selectedPolicyIds]); + // managed policies cannot be removed + const updateSelectedPolicyIds = useCallback( + (ids: string[]) => { + setSelectedPolicyIds( + uniq([ + ...selectedAgentPolicies.filter((policy) => policy.is_managed).map((policy) => policy.id), + ...ids, + ]) + ); + }, + [selectedAgentPolicies, setSelectedPolicyIds] + ); + return ( = ({ )} options={agentPolicyMultiOptions} selectedOptions={selectedOptions} - onChange={(newOptions) => { - setSelectedPolicyIds(newOptions.map((option: any) => option.key)); - }} + onChange={(newOptions) => + updateSelectedPolicyIds(newOptions.map((option: any) => option.key)) + } isClearable={true} isLoading={isLoading} /> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_options.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_options.tsx new file mode 100644 index 0000000000000..c39466d779548 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_options.tsx @@ -0,0 +1,177 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import type { EuiComboBoxOptionOption, EuiSuperSelectOption } from '@elastic/eui'; +import { EuiIcon, EuiSpacer, EuiText, EuiToolTip } from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n-react'; + +import type { AgentPolicy, Output, PackageInfo } from '../../../../../../../../../common'; +import { + FLEET_APM_PACKAGE, + PACKAGE_POLICY_SAVED_OBJECT_TYPE, + SO_SEARCH_LIMIT, +} from '../../../../../../../../../common'; +import { outputType } from '../../../../../../../../../common/constants'; +import { isPackageLimited } from '../../../../../../../../../common/services'; +import { useGetAgentPolicies, useGetOutputs, useGetPackagePolicies } from '../../../../../../hooks'; + +export function useAgentPoliciesOptions(packageInfo?: PackageInfo) { + // Fetch agent policies info + const { + data: agentPoliciesData, + error: agentPoliciesError, + isLoading: isAgentPoliciesLoading, + } = useGetAgentPolicies({ + page: 1, + perPage: SO_SEARCH_LIMIT, + sortField: 'name', + sortOrder: 'asc', + noAgentCount: true, // agentPolicy.agents will always be 0 + full: false, // package_policies will always be empty + }); + const agentPolicies = useMemo( + () => agentPoliciesData?.items.filter((policy) => !policy.is_managed) || [], + [agentPoliciesData?.items] + ); + + const { data: outputsData, isLoading: isOutputLoading } = useGetOutputs(); + + // get all package policies with apm integration or the current integration + const { data: packagePoliciesForThisPackage, isLoading: isLoadingPackagePolicies } = + useGetPackagePolicies({ + page: 1, + perPage: SO_SEARCH_LIMIT, + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name: ${packageInfo?.name}`, + }); + + const packagePoliciesForThisPackageByAgentPolicyId = useMemo( + () => + packagePoliciesForThisPackage?.items.reduce( + (acc: { [key: string]: boolean }, packagePolicy) => { + packagePolicy.policy_ids.forEach((policyId) => { + acc[policyId] = true; + }); + return acc; + }, + {} + ), + [packagePoliciesForThisPackage?.items] + ); + + const { getDataOutputForPolicy } = useMemo(() => { + const defaultOutput = (outputsData?.items ?? []).find((output) => output.is_default); + const outputsById = (outputsData?.items ?? []).reduce( + (acc: { [key: string]: Output }, output) => { + acc[output.id] = output; + return acc; + }, + {} + ); + + return { + getDataOutputForPolicy: (policy: Pick) => { + return policy.data_output_id ? outputsById[policy.data_output_id] : defaultOutput; + }, + }; + }, [outputsData]); + + const agentPolicyOptions: Array> = useMemo( + () => + packageInfo + ? agentPolicies.map((policy) => { + const isLimitedPackageAlreadyInPolicy = + isPackageLimited(packageInfo!) && + packagePoliciesForThisPackageByAgentPolicyId?.[policy.id]; + + const isAPMPackageAndDataOutputIsLogstash = + packageInfo?.name === FLEET_APM_PACKAGE && + getDataOutputForPolicy(policy)?.type === outputType.Logstash; + + return { + inputDisplay: ( + <> + {policy.name} + {isAPMPackageAndDataOutputIsLogstash && ( + <> + + + + + + )} + + ), + value: policy.id, + disabled: isLimitedPackageAlreadyInPolicy || isAPMPackageAndDataOutputIsLogstash, + 'data-test-subj': 'agentPolicyItem', + }; + }) + : [], + [ + packageInfo, + agentPolicies, + packagePoliciesForThisPackageByAgentPolicyId, + getDataOutputForPolicy, + ] + ); + + const agentPolicyMultiOptions: Array> = useMemo( + () => + packageInfo && !isOutputLoading && !isAgentPoliciesLoading && !isLoadingPackagePolicies + ? agentPolicies.map((policy) => { + const isLimitedPackageAlreadyInPolicy = + isPackageLimited(packageInfo!) && + packagePoliciesForThisPackageByAgentPolicyId?.[policy.id]; + + const isAPMPackageAndDataOutputIsLogstash = + packageInfo?.name === FLEET_APM_PACKAGE && + getDataOutputForPolicy(policy)?.type === outputType.Logstash; + + return { + append: isAPMPackageAndDataOutputIsLogstash ? ( + + } + > + + + ) : null, + key: policy.id, + label: policy.name, + disabled: isLimitedPackageAlreadyInPolicy || isAPMPackageAndDataOutputIsLogstash, + 'data-test-subj': 'agentPolicyMultiItem', + }; + }) + : [], + [ + packageInfo, + agentPolicies, + packagePoliciesForThisPackageByAgentPolicyId, + getDataOutputForPolicy, + isOutputLoading, + isAgentPoliciesLoading, + isLoadingPackagePolicies, + ] + ); + + return { + agentPoliciesError, + isLoading: isOutputLoading || isAgentPoliciesLoading || isLoadingPackagePolicies, + agentPolicyOptions, + agentPolicies, + agentPolicyMultiOptions, + }; +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.test.tsx index 33ff461f7efd5..30688c7a99b11 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.test.tsx @@ -76,7 +76,7 @@ describe('step select agent policy', () => { agentPolicies={[]} updateAgentPolicies={updateAgentPoliciesMock} setHasAgentPolicyError={mockSetHasAgentPolicyError} - selectedAgentPolicyIds={selectedAgentPolicyIds} + initialSelectedAgentPolicyIds={selectedAgentPolicyIds} /> )); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.tsx index f28593d84ef9a..6238a2cc62a07 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.tsx @@ -5,12 +5,10 @@ * 2.0. */ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { EuiComboBoxOptionOption, EuiSuperSelectOption } from '@elastic/eui'; -import { EuiIcon, EuiToolTip } from '@elastic/eui'; import { EuiSuperSelect } from '@elastic/eui'; import { EuiFlexGroup, @@ -19,29 +17,18 @@ import { EuiDescribedFormGroup, EuiTitle, EuiText, - EuiSpacer, } from '@elastic/eui'; import { Error } from '../../../../../components'; -import type { AgentPolicy, Output, PackageInfo } from '../../../../../types'; + +import type { AgentPolicy, PackageInfo } from '../../../../../types'; import { isPackageLimited, doesAgentPolicyAlreadyIncludePackage } from '../../../../../services'; -import { - useGetAgentPolicies, - useGetOutputs, - useFleetStatus, - useGetPackagePolicies, - sendBulkGetAgentPolicies, -} from '../../../../../hooks'; -import { - FLEET_APM_PACKAGE, - SO_SEARCH_LIMIT, - outputType, - PACKAGE_POLICY_SAVED_OBJECT_TYPE, -} from '../../../../../../../../common/constants'; +import { useFleetStatus, sendBulkGetAgentPolicies } from '../../../../../hooks'; import { useMultipleAgentPolicies } from '../../../../../hooks'; import { AgentPolicyMultiSelect } from './components/agent_policy_multi_select'; +import { useAgentPoliciesOptions } from './components/agent_policy_options'; const AgentPolicyFormRow = styled(EuiFormRow)` .euiFormRow__label { @@ -49,161 +36,6 @@ const AgentPolicyFormRow = styled(EuiFormRow)` } `; -function useAgentPoliciesOptions(packageInfo?: PackageInfo) { - // Fetch agent policies info - const { - data: agentPoliciesData, - error: agentPoliciesError, - isLoading: isAgentPoliciesLoading, - } = useGetAgentPolicies({ - page: 1, - perPage: SO_SEARCH_LIMIT, - sortField: 'name', - sortOrder: 'asc', - noAgentCount: true, // agentPolicy.agents will always be 0 - full: false, // package_policies will always be empty - }); - const agentPolicies = useMemo( - () => agentPoliciesData?.items.filter((policy) => !policy.is_managed) || [], - [agentPoliciesData?.items] - ); - - const { data: outputsData, isLoading: isOutputLoading } = useGetOutputs(); - - // get all package policies with apm integration or the current integration - const { data: packagePoliciesForThisPackage, isLoading: isLoadingPackagePolicies } = - useGetPackagePolicies({ - page: 1, - perPage: SO_SEARCH_LIMIT, - kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name: ${packageInfo?.name}`, - }); - - const packagePoliciesForThisPackageByAgentPolicyId = useMemo( - () => - packagePoliciesForThisPackage?.items.reduce( - (acc: { [key: string]: boolean }, packagePolicy) => { - packagePolicy.policy_ids.forEach((policyId) => { - acc[policyId] = true; - }); - return acc; - }, - {} - ), - [packagePoliciesForThisPackage?.items] - ); - - const { getDataOutputForPolicy } = useMemo(() => { - const defaultOutput = (outputsData?.items ?? []).find((output) => output.is_default); - const outputsById = (outputsData?.items ?? []).reduce( - (acc: { [key: string]: Output }, output) => { - acc[output.id] = output; - return acc; - }, - {} - ); - - return { - getDataOutputForPolicy: (policy: Pick) => { - return policy.data_output_id ? outputsById[policy.data_output_id] : defaultOutput; - }, - }; - }, [outputsData]); - - const agentPolicyOptions: Array> = useMemo( - () => - packageInfo - ? agentPolicies.map((policy) => { - const isLimitedPackageAlreadyInPolicy = - isPackageLimited(packageInfo) && - packagePoliciesForThisPackageByAgentPolicyId?.[policy.id]; - - const isAPMPackageAndDataOutputIsLogstash = - packageInfo?.name === FLEET_APM_PACKAGE && - getDataOutputForPolicy(policy)?.type === outputType.Logstash; - - return { - inputDisplay: ( - <> - {policy.name} - {isAPMPackageAndDataOutputIsLogstash && ( - <> - - - - - - )} - - ), - value: policy.id, - disabled: isLimitedPackageAlreadyInPolicy || isAPMPackageAndDataOutputIsLogstash, - 'data-test-subj': 'agentPolicyItem', - }; - }) - : [], - [ - packageInfo, - agentPolicies, - packagePoliciesForThisPackageByAgentPolicyId, - getDataOutputForPolicy, - ] - ); - - const agentPolicyMultiOptions: Array> = useMemo( - () => - packageInfo && !isOutputLoading && !isAgentPoliciesLoading && !isLoadingPackagePolicies - ? agentPolicies.map((policy) => { - const isLimitedPackageAlreadyInPolicy = - isPackageLimited(packageInfo) && - packagePoliciesForThisPackageByAgentPolicyId?.[policy.id]; - - const isAPMPackageAndDataOutputIsLogstash = - packageInfo?.name === FLEET_APM_PACKAGE && - getDataOutputForPolicy(policy)?.type === outputType.Logstash; - - return { - append: isAPMPackageAndDataOutputIsLogstash ? ( - - } - > - - - ) : null, - key: policy.id, - label: policy.name, - disabled: isLimitedPackageAlreadyInPolicy || isAPMPackageAndDataOutputIsLogstash, - 'data-test-subj': 'agentPolicyMultiItem', - }; - }) - : [], - [ - packageInfo, - agentPolicies, - packagePoliciesForThisPackageByAgentPolicyId, - getDataOutputForPolicy, - isOutputLoading, - isAgentPoliciesLoading, - isLoadingPackagePolicies, - ] - ); - - return { - agentPoliciesError, - isLoading: isOutputLoading || isAgentPoliciesLoading || isLoadingPackagePolicies, - agentPolicyOptions, - agentPolicies, - agentPolicyMultiOptions, - }; -} - function doesAgentPolicyHaveLimitedPackage(policy: AgentPolicy, pkgInfo: PackageInfo) { return policy ? isPackageLimited(pkgInfo) && doesAgentPolicyAlreadyIncludePackage(policy, pkgInfo.name) @@ -215,13 +47,13 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ agentPolicies: AgentPolicy[]; updateAgentPolicies: (agentPolicies: AgentPolicy[]) => void; setHasAgentPolicyError: (hasError: boolean) => void; - selectedAgentPolicyIds: string[]; + initialSelectedAgentPolicyIds: string[]; }> = ({ packageInfo, agentPolicies, updateAgentPolicies: updateSelectedAgentPolicies, setHasAgentPolicyError, - selectedAgentPolicyIds, + initialSelectedAgentPolicyIds, }) => { const { isReady: isFleetReady } = useFleetStatus(); @@ -239,7 +71,6 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ const [selectedPolicyIds, setSelectedPolicyIds] = useState([]); const [isFirstLoad, setIsFirstLoad] = useState(true); - const [isLoadingSelectedAgentPolicies, setIsLoadingSelectedAgentPolicies] = useState(false); const [selectedAgentPolicies, setSelectedAgentPolicies] = useState(agentPolicies); @@ -292,17 +123,17 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ setIsFirstLoad(false); if (canUseMultipleAgentPolicies) { const enabledOptions = agentPolicyMultiOptions.filter((option) => !option.disabled); - if (enabledOptions.length === 1) { + if (enabledOptions.length === 1 && initialSelectedAgentPolicyIds.length === 0) { setSelectedPolicyIds([enabledOptions[0].key!]); - } else if (selectedAgentPolicyIds.length > 0) { - setSelectedPolicyIds(selectedAgentPolicyIds); + } else if (initialSelectedAgentPolicyIds.length > 0) { + setSelectedPolicyIds(initialSelectedAgentPolicyIds); } } else { const enabledOptions = agentPolicyOptions.filter((option) => !option.disabled); if (enabledOptions.length === 1) { setSelectedPolicyIds([enabledOptions[0].value]); - } else if (selectedAgentPolicyIds.length > 0) { - setSelectedPolicyIds(selectedAgentPolicyIds); + } else if (initialSelectedAgentPolicyIds.length > 0) { + setSelectedPolicyIds(initialSelectedAgentPolicyIds); } } } @@ -310,7 +141,7 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ agentPolicyOptions, agentPolicyMultiOptions, canUseMultipleAgentPolicies, - selectedAgentPolicyIds, + initialSelectedAgentPolicyIds, selectedPolicyIds, existingAgentPolicies, isFirstLoad, @@ -346,7 +177,7 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ const someNewAgentPoliciesHaveLimitedPackage = !packageInfo || selectedAgentPolicies - .filter((policy) => !selectedAgentPolicyIds.find((id) => policy.id === id)) + .filter((policy) => !initialSelectedAgentPolicyIds.find((id) => policy.id === id)) .some((selectedAgentPolicy) => doesAgentPolicyHaveLimitedPackage(selectedAgentPolicy, packageInfo) ); @@ -426,6 +257,7 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ selectedPolicyIds={selectedPolicyIds} setSelectedPolicyIds={setSelectedPolicyIds} agentPolicyMultiOptions={agentPolicyMultiOptions} + selectedAgentPolicies={agentPolicies} /> ) : ( = ({ agentPolicies={agentPolicies} updateAgentPolicies={updateAgentPolicies} setHasAgentPolicyError={setHasAgentPolicyError} - selectedAgentPolicyIds={selectedAgentPolicyIds} + initialSelectedAgentPolicyIds={selectedAgentPolicyIds} /> ), }, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx index 7d50d3e494dbb..8e52ced483d72 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx @@ -23,6 +23,7 @@ import { sendBulkGetAgentPolicies, useGetAgentPolicies, useMultipleAgentPolicies, + useGetPackagePolicies, } from '../../../hooks'; import { useGetOnePackagePolicy } from '../../../../integrations/hooks'; @@ -134,6 +135,18 @@ jest.mock('../../../hooks', () => { sendCreateAgentPolicy: jest.fn(), sendBulkGetAgentPolicies: jest.fn(), sendBulkInstallPackages: jest.fn(), + useGetPackagePolicies: jest.fn(), + useGetOutputs: jest.fn().mockReturnValue({ + data: { + items: [ + { + id: 'logstash-1', + type: 'logstash', + }, + ], + }, + isLoading: false, + }), }; }); @@ -223,8 +236,11 @@ describe('edit package policy page', () => { item: mockPackagePolicy, }, }); - (sendGetOneAgentPolicy as MockFn).mockResolvedValue({ - data: { item: { id: 'agent-policy-1', name: 'Agent policy 1', namespace: 'default' } }, + (useGetPackagePolicies as MockFn).mockReturnValue({ + data: { + items: [mockPackagePolicy], + }, + isLoading: false, }); (sendUpgradePackagePolicyDryRun as MockFn).mockResolvedValue({ data: [ @@ -496,6 +512,7 @@ describe('edit package policy page', () => { (sendGetAgentStatus as jest.MockedFunction).mockResolvedValue({ data: { results: { total: 0 } }, }); + jest.clearAllMocks(); }); it('should create agent policy with sys monitoring when new hosts is selected', async () => { @@ -539,5 +556,60 @@ describe('edit package policy page', () => { }) ); }); + + it('should not remove managed policy when policies are modified', async () => { + (sendBulkGetAgentPolicies as MockFn).mockImplementation((ids: string[]) => { + const items = []; + if (ids.includes('agent-policy-1')) { + items.push({ id: 'agent-policy-1', name: 'Agent policy 1', is_managed: true }); + } + if (ids.includes('fleet-server-policy')) { + items.push({ id: 'fleet-server-policy', name: 'Fleet Server Policy' }); + } + return Promise.resolve({ + data: { + items, + }, + }); + }); + (useGetAgentPolicies as MockFn).mockReturnValue({ + data: { + items: [ + { id: 'agent-policy-1', name: 'Agent policy 1', is_managed: true }, + { id: 'fleet-server-policy', name: 'Fleet Server Policy' }, + ], + }, + isLoading: false, + }); + + await act(async () => { + render(); + }); + expect(renderResult.getByTestId('agentPolicyMultiSelect')).toBeInTheDocument(); + + await act(async () => { + renderResult.getByTestId('comboBoxToggleListButton').click(); + }); + + expect(renderResult.queryByText('Agent policy 1')).toBeNull(); + + await act(async () => { + fireEvent.click(renderResult.getByText('Fleet Server Policy')); + }); + + await act(async () => { + fireEvent.click(renderResult.getByText(/Save integration/).closest('button')!); + }); + await act(async () => { + fireEvent.click(renderResult.getAllByText(/Save and deploy changes/)[1].closest('button')!); + }); + + expect(sendUpdatePackagePolicy).toHaveBeenCalledWith( + 'nginx-1', + expect.objectContaining({ + policy_ids: ['agent-policy-1', 'fleet-server-policy'], + }) + ); + }); }); }); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx index 50b5b6e89a3ef..cc91af6a873a8 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx @@ -31,6 +31,7 @@ import { AgentPolicyRefreshContext, useIsPackagePolicyUpgradable, useAuthz, + useMultipleAgentPolicies, } from '../../../../../hooks'; import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../../../constants'; import { @@ -41,8 +42,6 @@ import { } from '../../../../../components'; import { SideBarColumn } from '../../../components/side_bar_column'; -import { useMultipleAgentPolicies } from '../../../../../hooks'; - import { PackagePolicyAgentsCell } from './components/package_policy_agents_cell'; import { usePackagePoliciesWithAgentPolicy } from './use_package_policies_with_agent_policy'; import { Persona } from './persona'; @@ -234,10 +233,14 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps defaultMessage: 'Agent policy', }), truncateText: true, - render(id, { agentPolicies }) { + render(id, { agentPolicies, packagePolicy }) { return agentPolicies.length > 0 ? ( - canShowMultiplePoliciesCell && agentPolicies.length > 1 ? ( - + canShowMultiplePoliciesCell ? ( + ) : ( ) @@ -328,6 +331,7 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps canAddFleetServers, canAddAgents, showAddAgentHelpForPackagePolicyId, + refreshPolicies, ] ); diff --git a/x-pack/plugins/fleet/public/components/manage_agent_policies_modal.test.tsx b/x-pack/plugins/fleet/public/components/manage_agent_policies_modal.test.tsx new file mode 100644 index 0000000000000..fb550a157d8b6 --- /dev/null +++ b/x-pack/plugins/fleet/public/components/manage_agent_policies_modal.test.tsx @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { act } from '@testing-library/react'; + +import type { TestRenderer } from '../mock'; +import { createFleetTestRendererMock } from '../mock'; +import type { AgentPolicy } from '../types'; + +import { usePackagePolicyWithRelatedData } from '../applications/fleet/sections/agent_policy/edit_package_policy_page/hooks'; + +import { useGetAgentPolicies } from '../hooks'; + +import { ManageAgentPoliciesModal } from './manage_agent_policies_modal'; + +jest.mock('../applications/fleet/sections/agent_policy/edit_package_policy_page/hooks', () => ({ + ...jest.requireActual( + '../applications/fleet/sections/agent_policy/edit_package_policy_page/hooks' + ), + usePackagePolicyWithRelatedData: jest.fn().mockReturnValue({ + packageInfo: {}, + packagePolicy: { name: 'Integration 1' }, + savePackagePolicy: jest.fn().mockResolvedValue({ error: undefined }), + }), +})); + +jest.mock('../hooks', () => ({ + ...jest.requireActual('../hooks'), + useStartServices: jest.fn().mockReturnValue({ + notifications: { + toasts: { + addSuccess: jest.fn(), + addError: jest.fn(), + }, + }, + }), + useGetAgentPolicies: jest.fn(), + useGetPackagePolicies: jest.fn().mockReturnValue({ + data: { + items: [{ name: 'Integration 1', revision: 2, id: 'integration1', policy_ids: ['policy1'] }], + }, + isLoading: false, + }), + useGetOutputs: jest.fn().mockReturnValue({ + data: { + items: [ + { + id: 'logstash-1', + type: 'logstash', + }, + ], + }, + isLoading: false, + }), +})); + +describe('ManageAgentPoliciesModal', () => { + let testRenderer: TestRenderer; + const mockOnClose = jest.fn(); + const mockPolicies = [{ name: 'Test policy', revision: 2, id: 'policy1' }] as AgentPolicy[]; + + const render = (policies?: AgentPolicy[]) => + testRenderer.render( + + ); + + beforeEach(() => { + testRenderer = createFleetTestRendererMock(); + + (useGetAgentPolicies as jest.Mock).mockReturnValue({ + data: { + items: [ + { name: 'Test policy', revision: 2, id: 'policy1' }, + { name: 'Test policy 2', revision: 1, id: 'policy2' }, + ] as AgentPolicy[], + }, + isLoading: false, + }); + }); + + it('should update policy on submit', async () => { + const results = render(); + + expect(results.queryByTestId('manageAgentPoliciesModal')).toBeInTheDocument(); + expect(results.getByTestId('integrationNameText').textContent).toEqual( + 'Integration: Integration 1' + ); + + await act(async () => { + results.getByTestId('comboBoxToggleListButton').click(); + }); + await act(async () => { + results.getByText('Test policy 2').click(); + }); + expect(results.getByText('Confirm').getAttribute('disabled')).toBeNull(); + await act(async () => { + results.getByText('Confirm').click(); + }); + expect(usePackagePolicyWithRelatedData('', {}).savePackagePolicy).toHaveBeenCalledWith({ + policy_ids: ['policy1', 'policy2'], + }); + }); + + it('should keep managed policy when policies are changed', async () => { + (useGetAgentPolicies as jest.Mock).mockReturnValue({ + data: { + items: [ + { name: 'Test policy', revision: 2, id: 'policy1', is_managed: true }, + { name: 'Test policy 2', revision: 1, id: 'policy2' }, + ] as AgentPolicy[], + }, + isLoading: false, + }); + const results = render([ + { name: 'Test policy', revision: 2, id: 'policy1', is_managed: true }, + ] as AgentPolicy[]); + + expect(results.queryByTestId('manageAgentPoliciesModal')).toBeInTheDocument(); + expect(results.getByTestId('integrationNameText').textContent).toEqual( + 'Integration: Integration 1' + ); + + await act(async () => { + results.getByTestId('comboBoxToggleListButton').click(); + }); + expect(results.queryByText('Test policy')).toBeNull(); + await act(async () => { + results.getByText('Test policy 2').click(); + }); + expect(results.getByText('Confirm').getAttribute('disabled')).toBeNull(); + await act(async () => { + results.getByText('Confirm').click(); + }); + expect(usePackagePolicyWithRelatedData('', {}).savePackagePolicy).toHaveBeenCalledWith({ + policy_ids: ['policy1', 'policy2'], + }); + }); + + it('should display callout and disable confirm if policy is removed', async () => { + const results = render(); + + await act(async () => { + results.getByTestId('comboBoxClearButton').click(); + }); + expect(results.getByText('Confirm').getAttribute('disabled')).toBeDefined(); + expect(results.getByTestId('confirmRemovePoliciesCallout')).toBeInTheDocument(); + expect(results.getByTestId('confirmRemovePoliciesCallout').textContent).toContain( + 'Test policy will no longer use this integration.' + ); + }); +}); diff --git a/x-pack/plugins/fleet/public/components/manage_agent_policies_modal.tsx b/x-pack/plugins/fleet/public/components/manage_agent_policies_modal.tsx new file mode 100644 index 0000000000000..26e9421ed86fe --- /dev/null +++ b/x-pack/plugins/fleet/public/components/manage_agent_policies_modal.tsx @@ -0,0 +1,218 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiCallOut, + EuiConfirmModal, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiText, +} from '@elastic/eui'; +import React, { useState, useMemo } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { i18n } from '@kbn/i18n'; + +import { isEqual } from 'lodash'; +import styled from 'styled-components'; + +import { AgentPolicyMultiSelect } from '../applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_multi_select'; +import { useAgentPoliciesOptions } from '../applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/agent_policy_options'; +import type { AgentPolicy } from '../types'; +import { usePackagePolicyWithRelatedData } from '../applications/fleet/sections/agent_policy/edit_package_policy_page/hooks'; +import { useStartServices } from '../hooks'; + +const StyledEuiConfirmModal = styled(EuiConfirmModal)` + min-width: 448px; +`; + +interface Props { + onClose: () => void; + selectedAgentPolicies: AgentPolicy[]; + packagePolicyId: string; + onAgentPoliciesChange: () => void; +} + +export const ManageAgentPoliciesModal: React.FunctionComponent = ({ + onClose, + selectedAgentPolicies, + packagePolicyId, + onAgentPoliciesChange, +}) => { + const initialPolicyIds = selectedAgentPolicies.map((policy) => policy.id); + + const [selectedPolicyIds, setSelectedPolicyIds] = useState(initialPolicyIds); + const [isSubmitting, setIsSubmitting] = useState(false); + const { notifications } = useStartServices(); + const { packageInfo, packagePolicy, savePackagePolicy } = usePackagePolicyWithRelatedData( + packagePolicyId, + {} + ); + + const removedPolicies = useMemo( + () => + selectedAgentPolicies + .filter((policy) => !selectedPolicyIds.find((id) => policy.id === id)) + .map((policy) => policy.name), + [selectedAgentPolicies, selectedPolicyIds] + ); + + const onCancel = () => { + onClose(); + }; + + const onConfirm = async () => { + setIsSubmitting(true); + const { error } = await savePackagePolicy({ + policy_ids: selectedPolicyIds, + }); + setIsSubmitting(false); + if (!error) { + onAgentPoliciesChange(); + notifications.toasts.addSuccess({ + title: i18n.translate('xpack.fleet.manageAgentPolicies.updatedNotificationTitle', { + defaultMessage: `Successfully updated ''{packagePolicyName}''`, + values: { + packagePolicyName: packagePolicy.name, + }, + }), + 'data-test-subj': 'policyUpdateSuccessToast', + }); + } else { + if (error.statusCode === 409) { + notifications.toasts.addError(error, { + title: i18n.translate('xpack.fleet.manageAgentPolicies.failedNotificationTitle', { + defaultMessage: `Error updating ''{packagePolicyName}''`, + values: { + packagePolicyName: packagePolicy.name, + }, + }), + toastMessage: i18n.translate( + 'xpack.fleet.manageAgentPolicies.failedConflictNotificationMessage', + { + defaultMessage: `Data is out of date. Refresh the page to get the latest policy.`, + } + ), + }); + } else { + notifications.toasts.addError(error, { + title: i18n.translate('xpack.fleet.manageAgentPolicies.failedNotificationTitle', { + defaultMessage: `Error updating ''{packagePolicyName}''`, + values: { + packagePolicyName: packagePolicy.name, + }, + }), + }); + } + } + onClose(); + }; + + const { agentPolicyMultiOptions, isLoading } = useAgentPoliciesOptions(packageInfo); + + return ( + + } + onCancel={onCancel} + onConfirm={onConfirm} + cancelButtonText={ + + } + confirmButtonText={ + + } + buttonColor="primary" + confirmButtonDisabled={ + selectedPolicyIds.length === 0 || + isSubmitting || + isEqual(initialPolicyIds, selectedPolicyIds) + } + data-test-subj="manageAgentPoliciesModal" + > + + + + + + + + + + + + {packagePolicy.name} + + + + + } + > + + + + {removedPolicies.length > 0 && ( + + + } + > + + {removedPolicies.join(', ')} }} + /> + + + + )} + + + + + + + + ); +}; diff --git a/x-pack/plugins/fleet/public/components/multiple_agent_policy_summary_line.test.tsx b/x-pack/plugins/fleet/public/components/multiple_agent_policy_summary_line.test.tsx index 0d88dcc4b44b7..100a6f67ad838 100644 --- a/x-pack/plugins/fleet/public/components/multiple_agent_policy_summary_line.test.tsx +++ b/x-pack/plugins/fleet/public/components/multiple_agent_policy_summary_line.test.tsx @@ -19,16 +19,22 @@ describe('MultipleAgentPolicySummaryLine', () => { let testRenderer: TestRenderer; const render = (agentPolicies: AgentPolicy[]) => - testRenderer.render(); + testRenderer.render( + + ); beforeEach(() => { testRenderer = createFleetTestRendererMock(); }); - test('it should render only the policy name when there is only one policy', async () => { + test('it should only render the policy name when there is only one policy', async () => { const results = render([{ name: 'Test policy', revision: 2 }] as AgentPolicy[]); - expect(results.container.textContent).toBe('Test policy'); - expect(results.queryByTestId('agentPolicyNameBadge')).toBeInTheDocument(); + expect(results.container.textContent).toBe('Test policyrev. 2'); + expect(results.queryByTestId('agentPolicyNameLink')).toBeInTheDocument(); expect(results.queryByTestId('agentPoliciesNumberBadge')).not.toBeInTheDocument(); }); @@ -38,7 +44,7 @@ describe('MultipleAgentPolicySummaryLine', () => { { name: 'Test policy 2', id: '0002' }, { name: 'Test policy 3', id: '0003' }, ] as AgentPolicy[]); - expect(results.queryByTestId('agentPolicyNameBadge')).toBeInTheDocument(); + expect(results.queryByTestId('agentPolicyNameLink')).toBeInTheDocument(); expect(results.queryByTestId('agentPoliciesNumberBadge')).toBeInTheDocument(); expect(results.container.textContent).toBe('Test policy 1+2'); @@ -50,5 +56,11 @@ describe('MultipleAgentPolicySummaryLine', () => { expect(results.queryByTestId('policy-0001')).toBeInTheDocument(); expect(results.queryByTestId('policy-0002')).toBeInTheDocument(); expect(results.queryByTestId('policy-0003')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(results.getByTestId('agentPoliciesPopoverButton')); + }); + + expect(results.queryByTestId('manageAgentPoliciesModal')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/fleet/public/components/multiple_agent_policy_summary_line.tsx b/x-pack/plugins/fleet/public/components/multiple_agent_policy_summary_line.tsx index 2a869f12bd817..0280989fb6eda 100644 --- a/x-pack/plugins/fleet/public/components/multiple_agent_policy_summary_line.tsx +++ b/x-pack/plugins/fleet/public/components/multiple_agent_policy_summary_line.tsx @@ -15,27 +15,41 @@ import { EuiButton, EuiListGroup, type EuiListGroupItemProps, + EuiLink, + EuiIconTip, + EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { CSSProperties } from 'react'; import { useMemo } from 'react'; import React, { memo, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + import type { AgentPolicy } from '../../common/types'; -import { useLink } from '../hooks'; +import { useAuthz, useLink } from '../hooks'; + +import { ManageAgentPoliciesModal } from './manage_agent_policies_modal'; const MIN_WIDTH: CSSProperties = { minWidth: 0 }; +const NO_WRAP_WHITE_SPACE: CSSProperties = { whiteSpace: 'nowrap' }; export const MultipleAgentPoliciesSummaryLine = memo<{ policies: AgentPolicy[]; direction?: 'column' | 'row'; -}>(({ policies, direction = 'row' }) => { + packagePolicyId: string; + onAgentPoliciesChange: () => void; +}>(({ policies, direction = 'row', packagePolicyId, onAgentPoliciesChange }) => { const { getHref } = useLink(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const closePopover = () => setIsPopoverOpen(false); + const [policiesModalEnabled, setPoliciesModalEnabled] = useState(false); + const authz = useAuthz(); + const canManageAgentPolicies = + authz.integrations.writeIntegrationPolicies && authz.fleet.allAgentPolicies; // as default, show only the first policy const policy = policies[0]; - const { name, id } = policy; + const { name, id, is_managed: isManaged, revision } = policy; const listItems: EuiListGroupItemProps[] = useMemo(() => { return policies.map((p) => { @@ -61,67 +75,120 @@ export const MultipleAgentPoliciesSummaryLine = memo<{ }, [getHref, policies]); return ( - - - - - - - - {name || id} - - - {policies.length > 1 && ( - - setIsPopoverOpen(!isPopoverOpen)} - onClickAriaLabel="Open agent policies popover" - > - {`+${policies.length - 1}`} - - + + + + + + + - - {i18n.translate('xpack.fleet.agentPolicySummaryLine.popover.title', { - defaultMessage: 'This integration is shared by', - })} - -
- + + {isManaged && ( + + + + )} + {revision && ( + + + -
- - {/* TODO: implement missing onClick function */} - - {i18n.translate('xpack.fleet.agentPolicySummaryLine.popover.button', { - defaultMessage: 'Manage agent policies', + +
+ )} + {policies.length > 1 && ( + + setIsPopoverOpen(!isPopoverOpen)} + onClickAriaLabel="Open agent policies popover" + > + +{policies.length - 1} + + + + {i18n.translate('xpack.fleet.agentPolicySummaryLine.popover.title', { + defaultMessage: 'This integration is shared by', })} - - - - - )} -
-
-
-
-
+ +
+ +
+ + setPoliciesModalEnabled(true)} + isDisabled={!canManageAgentPolicies} + > + {i18n.translate('xpack.fleet.agentPolicySummaryLine.popover.button', { + defaultMessage: 'Manage agent policies', + })} + + +
+
+ )} +
+
+
+
+
+ {policiesModalEnabled && ( + setPoliciesModalEnabled(false)} + onAgentPoliciesChange={onAgentPoliciesChange} + selectedAgentPolicies={policies} + packagePolicyId={packagePolicyId} + /> + )} + ); });