[Fleet] added modal to manage auto upgrade agents (#206955)

## Summary

Related to https://github.com/elastic/ingest-dev/issues/4721

Added Agent policy action `Manage auto-upgrade agents` to edit
`required_versions` config of agent policy on the UI.
Also added it to the Agent policy details header to open the modal.

To test:
- enable FF in `kibana.dev.yml`
  - `xpack.fleet.enableExperimental: ['enableAutomaticAgentUpgrades']`
- navigate to an Agent policy, click Actions and click `Manage
auto-upgrade agents`

<img width="1197" alt="image"
src="https://github.com/user-attachments/assets/de1afa92-c155-4072-8ed6-7e8263480199"
/>

Added validation on the UI (same as in the API) to prevent duplicate
versions, more than 100 percentages, empty percentage.
Added a callout to show how many agents are impacted by the update.

<img width="1044" alt="image"
src="https://github.com/user-attachments/assets/c889da73-ac8f-4c74-9bc5-113cb5b902c8"
/>

<img width="825" alt="image"
src="https://github.com/user-attachments/assets/cd9d797d-4343-44e8-bf29-b8683d5f83d4"
/>

Added `Auto-upgrade agents` with a `Manage` link to Agent policy details
header

<img width="1029" alt="image"
src="https://github.com/user-attachments/assets/733a21e6-4c78-4e83-b2cf-00a757921410"
/>

Moved the percentage helptext to a tooltip:

<img width="794" alt="image"
src="https://github.com/user-attachments/assets/3e133a50-e2aa-4de3-a49d-f312648fdc21"
/>



### Checklist

- [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/src/platform/packages/shared/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
This commit is contained in:
Julia Bardi 2025-01-21 17:22:08 +01:00 committed by GitHub
parent 3498d509ef
commit ff85b0fbff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 700 additions and 174 deletions

View file

@ -4,6 +4,9 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import semverValid from 'semver/functions/valid';
import type { AgentTargetVersion } from '../types';
export function removeSOAttributes(kuery: string): string {
return kuery.replace(/attributes\./g, '').replace(/fleet-agents\./g, '');
@ -20,3 +23,25 @@ export function getSortConfig(
: [];
return [{ [sortField]: { order: sortOrder } }, ...secondarySort];
}
export function checkTargetVersionsValidity(
requiredVersions: AgentTargetVersion[]
): string | undefined {
const versions = requiredVersions.map((v) => v.version);
const uniqueVersions = new Set(versions);
if (versions.length !== uniqueVersions.size) {
return `duplicate versions not allowed`;
}
if (requiredVersions.some((item) => !item.percentage)) {
return `percentage is required`;
}
for (const version of versions) {
if (!semverValid(version)) {
return `invalid semver version ${version}`;
}
}
const sumOfPercentages = requiredVersions.reduce((acc, v) => acc + v.percentage, 0);
if (sumOfPercentages > 100) {
return `sum of percentages cannot exceed 100`;
}
}

View file

@ -92,4 +92,4 @@ export {
isAgentVersionLessThanFleetServer,
} from './check_fleet_server_versions';
export { removeSOAttributes, getSortConfig } from './agent_utils';
export { removeSOAttributes, getSortConfig, checkTargetVersionsValidity } from './agent_utils';

View file

@ -7,6 +7,7 @@
import { setupFleetServer } from '../tasks/fleet_server';
import { AGENT_FLYOUT, AGENT_POLICY_DETAILS_PAGE } from '../screens/fleet';
import { login } from '../tasks/login';
import { visit } from '../tasks/common';
describe('Edit agent policy', () => {
beforeEach(() => {
@ -37,7 +38,7 @@ describe('Edit agent policy', () => {
});
it('should edit agent policy', () => {
cy.visit('/app/fleet/policies/policy-1/settings');
visit('/app/fleet/policies/policy-1/settings');
cy.get('[placeholder="Optional description"').clear().type('desc');
cy.intercept('/api/fleet/agent_policies/policy-1', {
@ -135,7 +136,7 @@ describe('Edit agent policy', () => {
},
});
cy.visit('/app/fleet/policies/policy-1');
visit('/app/fleet/policies/policy-1');
cy.getBySel(AGENT_POLICY_DETAILS_PAGE.ADD_AGENT_LINK).click();
cy.getBySel(AGENT_FLYOUT.KUBERNETES_PLATFORM_TYPE).click();

View file

@ -80,6 +80,10 @@ const disableNewFeaturesTours = (window: Window) => {
});
};
const disableFleetTours = (window: Window) => {
window.localStorage.setItem('fleet.autoUpgradeAgentsTour', JSON.stringify({ active: false }));
};
export const waitForPageToBeLoaded = () => {
cy.get(LOADING_INDICATOR_HIDDEN).should('exist');
cy.get(LOADING_INDICATOR).should('not.exist');
@ -115,6 +119,7 @@ export const visit = (url: string, options: Partial<Cypress.VisitOptions> = {},
options.onBeforeLoad?.(win);
disableNewFeaturesTours(win);
disableFleetTours(win);
},
onLoad: (win) => {
options.onLoad?.(win);

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { visit } from './common';
export const INTEGRATIONS = 'app/integrations#/';
export const FLEET = 'app/fleet/';
export const LOGIN_API_ENDPOINT = '/internal/security/login';
@ -16,5 +18,5 @@ export const hostDetailsUrl = (hostName: string) =>
`/app/security/hosts/${hostName}/authentications`;
export const navigateTo = (page: string) => {
cy.visit(page);
visit(page);
};

View file

@ -14,6 +14,8 @@ import type { Section } from '../../sections';
import { useLink, useConfig, useAuthz, useStartServices } from '../../hooks';
import { WithHeaderLayout } from '../../../../layouts';
import { AutoUpgradeAgentsTour } from '../../sections/agent_policy/components/auto_upgrade_agents_tour';
import { DefaultPageTitle } from './default_page_title';
interface Props {
@ -56,6 +58,7 @@ export const DefaultLayout: React.FunctionComponent<Props> = ({
isSelected: section === 'agent_policies',
href: getHref('policies_list'),
'data-test-subj': 'fleet-agent-policies-tab',
id: 'fleet-agent-policies-tab',
},
{
name: (
@ -141,6 +144,7 @@ export const DefaultLayout: React.FunctionComponent<Props> = ({
<WithHeaderLayout leftColumn={<DefaultPageTitle />} rightColumn={rightColumn} tabs={tabs}>
{children}
</WithHeaderLayout>
<AutoUpgradeAgentsTour anchor="#fleet-agent-policies-tab" />
</>
);
};

View file

@ -10,7 +10,7 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { EuiContextMenuItem, EuiPortal } from '@elastic/eui';
import type { AgentPolicy } from '../../../types';
import { useAuthz } from '../../../hooks';
import { useAgentPolicyRefresh, useAuthz } from '../../../hooks';
import {
AgentEnrollmentFlyout,
ContextMenuActions,
@ -22,6 +22,8 @@ import { policyHasFleetServer } from '../../../services';
import { AgentUpgradeAgentModal } from '../../agents/components';
import { ManageAutoUpgradeAgentsModal } from '../../agents/components/manage_auto_upgrade_agents_modal';
import { AgentPolicyYamlFlyout } from './agent_policy_yaml_flyout';
import { AgentPolicyCopyProvider } from './agent_policy_copy_provider';
import { AgentPolicyDeleteProvider } from './agent_policy_delete_provider';
@ -49,6 +51,9 @@ export const AgentPolicyActionMenu = memo<{
const [isUninstallCommandFlyoutOpen, setIsUninstallCommandFlyoutOpen] =
useState<boolean>(false);
const [isUpgradeAgentsModalOpen, setIsUpgradeAgentsModalOpen] = useState<boolean>(false);
const [isManageAutoUpgradeAgentsModalOpen, setIsManageAutoUpgradeAgentsModalOpen] =
useState<boolean>(false);
const refreshAgentPolicy = useAgentPolicyRefresh();
const isFleetServerPolicy = useMemo(
() =>
@ -98,6 +103,23 @@ export const AgentPolicyActionMenu = memo<{
</EuiContextMenuItem>
);
const manageAutoUpgradeAgentsItem = (
<EuiContextMenuItem
icon="gear"
disabled={!authz.fleet.allAgentPolicies}
onClick={() => {
setIsContextMenuOpen(false);
setIsManageAutoUpgradeAgentsModalOpen(!isManageAutoUpgradeAgentsModalOpen);
}}
key="manageAutoUpgradeAgents"
>
<FormattedMessage
id="xpack.fleet.agentPolicyActionMenu.manageAutoUpgradeAgentsText"
defaultMessage="Manage auto-upgrade agents"
/>
</EuiContextMenuItem>
);
const deletePolicyItem = (
<AgentPolicyDeleteProvider
hasFleetServer={policyHasFleetServer(agentPolicy as AgentPolicy)}
@ -187,6 +209,7 @@ export const AgentPolicyActionMenu = memo<{
)}
</EuiContextMenuItem>,
viewPolicyItem,
manageAutoUpgradeAgentsItem,
copyPolicyItem,
deletePolicyItem,
];
@ -273,6 +296,20 @@ export const AgentPolicyActionMenu = memo<{
/>
</EuiPortal>
)}
{isManageAutoUpgradeAgentsModalOpen && (
<EuiPortal>
<ManageAutoUpgradeAgentsModal
agentPolicy={agentPolicy}
agentCount={agentPolicy.agents || 0}
onClose={(refreshPolicy: boolean) => {
setIsManageAutoUpgradeAgentsModalOpen(false);
if (refreshPolicy) {
refreshAgentPolicy();
}
}}
/>
</EuiPortal>
)}
{isUninstallCommandFlyoutOpen && (
<UninstallCommandFlyout
target="agent"

View file

@ -0,0 +1,68 @@
/*
* 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 { EuiText, EuiTourStep } from '@elastic/eui';
import React, { useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import type { TOUR_STORAGE_CONFIG } from '../../../constants';
import { TOUR_STORAGE_KEYS } from '../../../constants';
import { useStartServices } from '../../../hooks';
export const AutoUpgradeAgentsTour: React.FC<{ anchor: string }> = ({ anchor }) => {
const { storage, uiSettings } = useStartServices();
const [tourState, setTourState] = useState({ isOpen: true });
const isTourHidden =
uiSettings.get('hideAnnouncements', false) ||
(
storage.get(TOUR_STORAGE_KEYS.AUTO_UPGRADE_AGENTS) as
| TOUR_STORAGE_CONFIG['AUTO_UPGRADE_AGENTS']
| undefined
)?.active === false;
const setTourAsHidden = () => {
storage.set(TOUR_STORAGE_KEYS.AUTO_UPGRADE_AGENTS, {
active: false,
} as TOUR_STORAGE_CONFIG['AUTO_UPGRADE_AGENTS']);
};
const onFinish = () => {
setTourState({ isOpen: false });
setTourAsHidden();
};
return (
<>
<EuiTourStep
content={
<EuiText>
<FormattedMessage
id="xpack.fleet.autoUpgradeAgentsTour.tourContent"
defaultMessage="Select your policy and configure target agent versions for automatic upgrades."
/>
</EuiText>
}
isStepOpen={!isTourHidden && tourState.isOpen}
onFinish={onFinish}
minWidth={360}
maxWidth={360}
step={1}
stepsTotal={1}
title={
<FormattedMessage
id="xpack.fleet.autoUpgradeAgentsTour.tourTitle"
defaultMessage="Auto-upgrade agents"
/>
}
anchorPosition="downLeft"
anchor={anchor}
/>
</>
);
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedDate, FormattedMessage } from '@kbn/i18n-react';
import styled from 'styled-components';
@ -20,14 +20,18 @@ import {
EuiLink,
EuiToolTip,
EuiIconTip,
EuiPortal,
EuiNotificationBadge,
} from '@elastic/eui';
import { useAuthz, useLink } from '../../../../../hooks';
import { useAgentPolicyRefresh, useAuthz, useLink } from '../../../../../hooks';
import type { AgentPolicy } from '../../../../../types';
import { AgentPolicyActionMenu, LinkedAgentCount } from '../../../components';
import { AddAgentHelpPopover } from '../../../../../components';
import { FLEET_SERVER_PACKAGE } from '../../../../../../../../common/constants';
import { getRootIntegrations } from '../../../../../../../../common/services';
import { ManageAutoUpgradeAgentsModal } from '../../../../agents/components/manage_auto_upgrade_agents_modal';
import { AutoUpgradeAgentsTour } from '../../../components/auto_upgrade_agents_tour';
export interface HeaderRightContentProps {
isLoading: boolean;
@ -55,6 +59,9 @@ export const HeaderRightContent: React.FunctionComponent<HeaderRightContentProps
const authz = useAuthz();
const { getPath } = useLink();
const history = useHistory();
const [isManageAutoUpgradeAgentsModalOpen, setIsManageAutoUpgradeAgentsModalOpen] =
useState<boolean>(false);
const refreshAgentPolicy = useAgentPolicyRefresh();
const isFleetServerPolicy = useMemo(
() =>
@ -84,154 +91,215 @@ export const HeaderRightContent: React.FunctionComponent<HeaderRightContentProps
);
return (
<EuiFlexGroup justifyContent={'flexEnd'} direction="row">
{isLoading || !agentPolicy
? null
: [
{
label: i18n.translate('xpack.fleet.policyDetails.summary.revision', {
defaultMessage: 'Revision',
}),
content: agentPolicy.revision ?? 0,
},
{ isDivider: true },
{
label: i18n.translate('xpack.fleet.policyDetails.summary.integrations', {
defaultMessage: 'Integrations',
}),
content: (
<EuiI18nNumber
value={(agentPolicy.package_policies && agentPolicy.package_policies.length) || 0}
/>
),
},
{ isDivider: true },
...(authz.fleet.readAgents && !agentPolicy?.supports_agentless
? [
{
label: i18n.translate('xpack.fleet.policyDetails.summary.usedBy', {
defaultMessage: 'Agents',
}),
content:
!agentPolicy.agents && isFleetServerPolicy && authz.fleet.addFleetServers ? (
<AddAgentHelpPopover
button={addFleetServerLink}
isOpen={isAddAgentHelpPopoverOpen}
offset={15}
closePopover={() => {
setIsAddAgentHelpPopoverOpen(false);
}}
/>
) : !agentPolicy.agents && !isFleetServerPolicy && authz.fleet.addAgents ? (
<AddAgentHelpPopover
button={addAgentLink}
isOpen={isAddAgentHelpPopoverOpen}
offset={15}
closePopover={() => {
setIsAddAgentHelpPopoverOpen(false);
}}
/>
) : (
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<EuiToolTip
content={
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem>
<FormattedMessage
id="xpack.fleet.policyDetails.summary.usedByUnprivilegedTooltip"
defaultMessage="{count, plural, one {# unprivileged agent} other {# unprivileged agents}}"
values={{ count: agentPolicy.unprivileged_agents || 0 }}
/>
</EuiFlexItem>
<EuiFlexItem>
<FormattedMessage
id="xpack.fleet.policyDetails.summary.usedByPrivilegedTooltip"
defaultMessage="{count, plural, one {# privileged agent} other {# privileged agents}}"
values={{
count:
(agentPolicy.agents || 0) -
(agentPolicy.unprivileged_agents || 0),
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
}
>
<LinkedAgentCount
count={agentPolicy.agents || 0}
agentPolicyId={agentPolicy.id}
showAgentText={true}
/>
</EuiToolTip>
</EuiFlexItem>
{getRootIntegrations(agentPolicy.package_policies || []).length > 0 &&
(agentPolicy.unprivileged_agents || 0) > 0 && (
<EuiFlexItem grow={false}>
<EuiIconTip
type="warning"
color="warning"
content={
<FormattedMessage
id="xpack.fleet.policyDetails.summary.containsUnprivilegedAgentsWarning"
defaultMessage="This agent policy contains integrations that require Elastic Agents to have root privileges. Some enrolled agents are running in unprivileged mode."
/>
}
<>
<EuiFlexGroup justifyContent={'flexEnd'} direction="row">
{isLoading || !agentPolicy
? null
: [
{
label: i18n.translate('xpack.fleet.policyDetails.summary.revision', {
defaultMessage: 'Revision',
}),
content: agentPolicy.revision ?? 0,
},
{ isDivider: true },
{
label: i18n.translate('xpack.fleet.policyDetails.summary.integrations', {
defaultMessage: 'Integrations',
}),
content: (
<EuiI18nNumber
value={
(agentPolicy.package_policies && agentPolicy.package_policies.length) || 0
}
/>
),
},
{ isDivider: true },
...(authz.fleet.readAgents && !agentPolicy?.supports_agentless
? [
{
label: i18n.translate('xpack.fleet.policyDetails.summary.usedBy', {
defaultMessage: 'Agents',
}),
content:
!agentPolicy.agents &&
isFleetServerPolicy &&
authz.fleet.addFleetServers ? (
<AddAgentHelpPopover
button={addFleetServerLink}
isOpen={isAddAgentHelpPopoverOpen}
offset={15}
closePopover={() => {
setIsAddAgentHelpPopoverOpen(false);
}}
/>
) : !agentPolicy.agents && !isFleetServerPolicy && authz.fleet.addAgents ? (
<AddAgentHelpPopover
button={addAgentLink}
isOpen={isAddAgentHelpPopoverOpen}
offset={15}
closePopover={() => {
setIsAddAgentHelpPopoverOpen(false);
}}
/>
) : (
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<EuiToolTip
content={
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem>
<FormattedMessage
id="xpack.fleet.policyDetails.summary.usedByUnprivilegedTooltip"
defaultMessage="{count, plural, one {# unprivileged agent} other {# unprivileged agents}}"
values={{ count: agentPolicy.unprivileged_agents || 0 }}
/>
</EuiFlexItem>
<EuiFlexItem>
<FormattedMessage
id="xpack.fleet.policyDetails.summary.usedByPrivilegedTooltip"
defaultMessage="{count, plural, one {# privileged agent} other {# privileged agents}}"
values={{
count:
(agentPolicy.agents || 0) -
(agentPolicy.unprivileged_agents || 0),
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
}
>
<LinkedAgentCount
count={agentPolicy.agents || 0}
agentPolicyId={agentPolicy.id}
showAgentText={true}
/>
</EuiFlexItem>
)}
</EuiToolTip>
</EuiFlexItem>
{getRootIntegrations(agentPolicy.package_policies || []).length > 0 &&
(agentPolicy.unprivileged_agents || 0) > 0 && (
<EuiFlexItem grow={false}>
<EuiIconTip
type="warning"
color="warning"
content={
<FormattedMessage
id="xpack.fleet.policyDetails.summary.containsUnprivilegedAgentsWarning"
defaultMessage="This agent policy contains integrations that require Elastic Agents to have root privileges. Some enrolled agents are running in unprivileged mode."
/>
}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
),
},
{ isDivider: true },
]
: []),
{
label: i18n.translate('xpack.fleet.policyDetails.summary.lastUpdated', {
defaultMessage: 'Last updated on',
}),
content:
(agentPolicy && (
<FormattedDate
value={agentPolicy?.updated_at}
year="numeric"
month="short"
day="2-digit"
/>
)) ||
'',
},
{ isDivider: true },
...(authz.fleet.allAgentPolicies
? [
{
label: i18n.translate('xpack.fleet.policyDetails.summary.autoUpgrade', {
defaultMessage: 'Auto-upgrade agents',
}),
content: (
<EuiFlexGroup
gutterSize="xs"
justifyContent="flexEnd"
alignItems="center"
id="auto-upgrade-manage-button"
>
<EuiFlexItem grow={false}>
<EuiLink
onClick={() => {
setIsManageAutoUpgradeAgentsModalOpen(
!isManageAutoUpgradeAgentsModalOpen
);
}}
>
<FormattedMessage
id="xpack.fleet.policyDetails.summary.autoUpgradeButton"
defaultMessage="Manage"
/>
</EuiLink>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiNotificationBadge
color={agentPolicy.required_versions?.length ? 'accent' : 'subdued'}
>
{agentPolicy.required_versions?.length || 0}
</EuiNotificationBadge>
</EuiFlexItem>
</EuiFlexGroup>
),
},
{ isDivider: true },
]
: []),
{
label: i18n.translate('xpack.fleet.policyDetails.summary.lastUpdated', {
defaultMessage: 'Last updated on',
}),
content:
(agentPolicy && (
<FormattedDate
value={agentPolicy?.updated_at}
year="numeric"
month="short"
day="2-digit"
},
{ isDivider: true },
]
: []),
{
content: agentPolicy && (
<AgentPolicyActionMenu
agentPolicy={agentPolicy}
fullButton={true}
onCopySuccess={(newAgentPolicy: AgentPolicy) => {
history.push(getPath('policy_details', { policyId: newAgentPolicy.id }));
}}
onCancelEnrollment={onCancelEnrollment}
/>
)) ||
'',
},
{ isDivider: true },
{
content: agentPolicy && (
<AgentPolicyActionMenu
agentPolicy={agentPolicy}
fullButton={true}
onCopySuccess={(newAgentPolicy: AgentPolicy) => {
history.push(getPath('policy_details', { policyId: newAgentPolicy.id }));
}}
onCancelEnrollment={onCancelEnrollment}
/>
),
},
].map((item, index) => (
<EuiFlexItem grow={false} key={index}>
{item.isDivider ?? false ? (
<Divider />
) : item.label ? (
<EuiDescriptionList compressed textStyle="reverse" style={{ textAlign: 'right' }}>
<EuiDescriptionListTitle className="eui-textNoWrap">
{item.label}
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textNoWrap">
{item.content}
</EuiDescriptionListDescription>
</EuiDescriptionList>
) : (
item.content
)}
</EuiFlexItem>
))}
</EuiFlexGroup>
),
},
].map((item, index) => (
<EuiFlexItem grow={false} key={index}>
{item.isDivider ?? false ? (
<Divider />
) : item.label ? (
<EuiDescriptionList compressed textStyle="reverse" style={{ textAlign: 'right' }}>
<EuiDescriptionListTitle className="eui-textNoWrap">
{item.label}
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textNoWrap">
{item.content}
</EuiDescriptionListDescription>
</EuiDescriptionList>
) : (
item.content
)}
</EuiFlexItem>
))}
</EuiFlexGroup>
{isManageAutoUpgradeAgentsModalOpen && (
<EuiPortal>
<ManageAutoUpgradeAgentsModal
agentPolicy={agentPolicy}
agentCount={agentPolicy.agents || 0}
onClose={(refreshPolicy: boolean) => {
setIsManageAutoUpgradeAgentsModalOpen(false);
if (refreshPolicy) {
refreshAgentPolicy();
}
}}
/>
</EuiPortal>
)}
<AutoUpgradeAgentsTour anchor="#auto-upgrade-manage-button" />
</>
);
};

View file

@ -0,0 +1,290 @@
/*
* 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, { useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiConfirmModal,
EuiFieldNumber,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiIconTip,
EuiSpacer,
EuiSuperSelect,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { AgentTargetVersion } from '../../../../../../../common/types';
import type { AgentPolicy } from '../../../../../../../common';
import { useGetAgentsAvailableVersionsQuery, useStartServices } from '../../../../../../hooks';
import { checkTargetVersionsValidity } from '../../../../../../../common/services';
import { sendUpdateAgentPolicyForRq } from '../../../../../../hooks/use_request/agent_policy';
export interface ManageAutoUpgradeAgentsModalProps {
onClose: (refreshPolicy: boolean) => void;
agentPolicy: AgentPolicy;
agentCount: number;
}
export const ManageAutoUpgradeAgentsModal: React.FunctionComponent<
ManageAutoUpgradeAgentsModalProps
> = ({ onClose, agentPolicy, agentCount }) => {
const [isLoading, setIsLoading] = useState<boolean>(false);
const { notifications } = useStartServices();
const [targetVersions, setTargetVersions] = useState(agentPolicy.required_versions || []);
const { data: agentsAvailableVersions } = useGetAgentsAvailableVersionsQuery({
enabled: true,
});
const latestVersion = agentsAvailableVersions?.items[0];
const [errors, setErrors] = useState<string[]>([]);
const submitUpdateAgentPolicy = async () => {
setIsLoading(true);
let isSuccess = false;
try {
await sendUpdateAgentPolicyForRq(agentPolicy.id, {
name: agentPolicy.name,
namespace: agentPolicy.namespace,
required_versions: targetVersions,
});
notifications.toasts.addSuccess(
i18n.translate('xpack.fleet.manageAutoUpgradeAgents.successNotificationTitle', {
defaultMessage: "Successfully updated ''{name}'' auto-upgrade agents settings",
values: { name: agentPolicy.name },
})
);
isSuccess = true;
} catch (e) {
notifications.toasts.addDanger(
i18n.translate('xpack.fleet.manageAutoUpgradeAgents.errorNotificationTitle', {
defaultMessage: 'Unable to update agent policy',
})
);
}
setIsLoading(false);
onClose(isSuccess);
};
async function onSubmit() {
await submitUpdateAgentPolicy();
}
async function updateTargetVersions(newVersions: AgentTargetVersion[]) {
const error = checkTargetVersionsValidity(newVersions);
setErrors(error ? [error] : []);
setTargetVersions(newVersions);
}
return (
<EuiConfirmModal
data-test-subj="manageAutoUpgradeAgentsModal"
title={
<FormattedMessage
id="xpack.fleet.manageAutoUpgradeAgents.modalTitle"
defaultMessage="Manage auto-upgrade agents"
/>
}
onCancel={() => onClose(false)}
onConfirm={onSubmit}
confirmButtonDisabled={isLoading || errors.length > 0}
cancelButtonText={
<FormattedMessage
id="xpack.fleet.manageAutoUpgradeAgents.cancelButtonLabel"
defaultMessage="Cancel"
/>
}
confirmButtonText={
<FormattedMessage
id="xpack.fleet.manageAutoUpgradeAgents.saveButtonLabel"
defaultMessage="Save"
/>
}
style={{ width: 1000 }}
>
<EuiFlexGroup direction="column">
<EuiFlexItem>
{agentCount > 0 ? (
<>
<EuiCallOut
iconType="iInCircle"
title={i18n.translate('xpack.fleet.manageAutoUpgradeAgents.calloutTitle', {
defaultMessage:
'This action will update {agentCount, plural, one {# agent} other {# agents}}',
values: {
agentCount,
},
})}
/>
<EuiSpacer size="m" />
</>
) : null}
<FormattedMessage
id="xpack.fleet.manageAutoUpgradeAgents.descriptionText"
defaultMessage="Add the target agent version for automatic upgrades."
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiForm isInvalid={errors.length > 0} error={errors} component="form">
{targetVersions.map((requiredVersion, index) => (
<>
<TargetVersionsRow
agentsAvailableVersions={agentsAvailableVersions?.items || []}
requiredVersion={requiredVersion}
key={index}
onRemove={() => {
updateTargetVersions(targetVersions.filter((_, i) => i !== index));
}}
onUpdate={(version: string, percentage: number) => {
updateTargetVersions(
targetVersions.map((targetVersion, i) =>
i === index ? { version, percentage } : targetVersion
)
);
}}
/>
<EuiSpacer size="s" />
</>
))}
</EuiForm>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow>
<EuiButtonEmpty
onClick={() => {
updateTargetVersions([
...targetVersions,
{
version: latestVersion || '',
percentage: targetVersions.length === 0 ? 100 : 1,
},
]);
}}
iconType="plusInCircle"
>
<FormattedMessage
id="xpack.fleet.manageAutoUpgradeAgents.addVersionButton"
defaultMessage="Add target version"
/>
</EuiButtonEmpty>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</EuiConfirmModal>
);
};
const TargetVersionsRow: React.FunctionComponent<{
agentsAvailableVersions: string[];
requiredVersion: AgentTargetVersion;
onRemove: () => void;
onUpdate: (version: string, percentage: number) => void;
}> = ({ agentsAvailableVersions, requiredVersion, onRemove, onUpdate }) => {
const options = agentsAvailableVersions.map((version) => ({
value: version,
inputDisplay: version,
}));
const [version, setVersion] = useState(requiredVersion.version);
const onVersionChange = (value: string) => {
setVersion(value);
};
const [percentage, setPercentage] = useState(requiredVersion.percentage);
const onPercentageChange = (value: number) => {
setPercentage(value);
};
return (
<EuiFlexGroup direction="row" alignItems="flexEnd">
<EuiFlexItem>
<EuiFormRow
label={
<>
<FormattedMessage
id="xpack.fleet.manageAutoUpgradeAgents.targetAgentVersionTitle"
defaultMessage="Target agent version"
/>
<EuiIconTip
type="iInCircle"
content={
<FormattedMessage
data-test-subj="targetVersionTooltip"
id="xpack.fleet.manageAutoUpgradeAgents.targetVersionTooltip"
defaultMessage="You can only downgrade agents manually."
/>
}
/>
</>
}
>
<EuiSuperSelect
options={options}
valueOfSelected={version}
onChange={(value) => {
onVersionChange(value);
onUpdate(value, percentage);
}}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={
<>
<FormattedMessage
id="xpack.fleet.manageAutoUpgradeAgents.percentageTitle"
defaultMessage="% of agents to upgrade"
/>
<EuiIconTip
type="iInCircle"
content={
<FormattedMessage
data-test-subj="percentageTooltip"
id="xpack.fleet.manageAutoUpgradeAgents.percentageTooltip"
defaultMessage="Set 100 to upgrade all agents in the policy."
/>
}
/>
</>
}
>
<EuiFieldNumber
value={percentage}
onChange={(e) => {
const newValue = parseInt(e.target.value, 10);
onPercentageChange(newValue);
onUpdate(version, newValue);
}}
min={0}
step={1}
max={100}
required
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow label="">
<EuiButton onClick={onRemove} color="text">
<FormattedMessage
id="xpack.fleet.manageAutoUpgradeAgents.removeVersionButton"
defaultMessage="Remove"
/>
</EuiButton>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -55,6 +55,7 @@ export const TOUR_STORAGE_KEYS = {
INACTIVE_AGENTS: 'fleet.inactiveAgentsTour',
GRANULAR_PRIVILEGES: 'fleet.granularPrivileges',
AGENT_EXPORT_CSV: 'fleet.agentExportCSVTour',
AUTO_UPGRADE_AGENTS: 'fleet.autoUpgradeAgentsTour',
};
export interface TourConfig {

View file

@ -156,6 +156,18 @@ export const sendUpdateAgentPolicy = (
});
};
export const sendUpdateAgentPolicyForRq = (
agentPolicyId: string,
body: UpdateAgentPolicyRequest['body']
) => {
return sendRequestForRq<UpdateAgentPolicyResponse>({
path: agentPolicyRouteService.getUpdatePath(agentPolicyId),
method: 'put',
body: JSON.stringify(body),
version: API_VERSIONS.public.v1,
});
};
export const sendCopyAgentPolicy = (
agentPolicyId: string,
body: CopyAgentPolicyRequest['body']

View file

@ -351,6 +351,21 @@ export function sendGetAgentsAvailableVersions() {
});
}
export function useGetAgentsAvailableVersionsQuery(options: Partial<{ enabled: boolean }> = {}) {
return useQuery(
['available_versions'],
() =>
sendRequestForRq<GetAvailableVersionsResponse>({
method: 'get',
path: agentRouteService.getAvailableVersionsPath(),
version: API_VERSIONS.public.v1,
}),
{
enabled: options.enabled,
}
);
}
export function sendGetAgentStatusRuntimeField() {
return sendRequestForRq<string>({
method: 'get',

View file

@ -40,7 +40,7 @@ describe('validateRequiredVersions', () => {
]);
}).toThrow(
new AgentPolicyInvalidError(
`Policy "test policy" failed validation: duplicate versions not allowed in required_versions`
`Policy "test policy" failed required_versions validation: duplicate versions not allowed`
)
);
});
@ -53,7 +53,7 @@ describe('validateRequiredVersions', () => {
]);
}).toThrow(
new AgentPolicyInvalidError(
`Policy "test policy" failed validation: invalid semver version 9.0.0invalid in required_versions`
`Policy "test policy" failed required_versions validation: invalid semver version 9.0.0invalid`
)
);
});
@ -66,7 +66,20 @@ describe('validateRequiredVersions', () => {
]);
}).toThrow(
new AgentPolicyInvalidError(
`Policy "test policy" failed validation: sum of required_versions percentages cannot exceed 100`
`Policy "test policy" failed required_versions validation: sum of percentages cannot exceed 100`
)
);
});
it('should throw error if percentage is 0 or undefined', () => {
expect(() => {
validateRequiredVersions('test policy', [
{ version: '9.0.0', percentage: 100 },
{ version: '9.1.0', percentage: 0 },
]);
}).toThrow(
new AgentPolicyInvalidError(
`Policy "test policy" failed required_versions validation: percentage is required`
)
);
});

View file

@ -5,12 +5,11 @@
* 2.0.
*/
import semverValid from 'semver/functions/valid';
import type { AgentTargetVersion } from '../../../common/types';
import { AgentPolicyInvalidError } from '../../errors';
import { appContextService } from '..';
import { checkTargetVersionsValidity } from '../../../common/services/agent_utils';
export function validateRequiredVersions(
name: string,
@ -24,24 +23,10 @@ export function validateRequiredVersions(
`Policy "${name}" failed validation: required_versions are not allowed when automatic upgrades feature is disabled`
);
}
const versions = requiredVersions.map((v) => v.version);
const uniqueVersions = new Set(versions);
if (versions.length !== uniqueVersions.size) {
const error = checkTargetVersionsValidity(requiredVersions);
if (error) {
throw new AgentPolicyInvalidError(
`Policy "${name}" failed validation: duplicate versions not allowed in required_versions`
);
}
versions.forEach((version) => {
if (!semverValid(version)) {
throw new AgentPolicyInvalidError(
`Policy "${name}" failed validation: invalid semver version ${version} in required_versions`
);
}
});
const sumOfPercentages = requiredVersions.reduce((acc, v) => acc + v.percentage, 0);
if (sumOfPercentages > 100) {
throw new AgentPolicyInvalidError(
`Policy "${name}" failed validation: sum of required_versions percentages cannot exceed 100`
`Policy "${name}" failed required_versions validation: ${error}`
);
}
}