[Fleet] Report automatic upgrade status in UI (#215069)

Closes
[4718](https://github.com/elastic/ingest-dev/issues/4718?reload=1?reload=1?reload=1%3Freload%3D1)

## Summary 


- After much discussion, updated to not show percentages but be very
similar to existing upgrade text, but with an icon/tooltip letting the
user know it was created from an automatic upgrade. Also applied to
completed actions.
- Made `policyId` persistent in newly created actions in order to
streamline accessing the upgrade modal from agent activity
- Added `is_automatic` field to `ActionStatus` type by retrieving from
the source doc in `getActions`
- Updated audit log to show if the action was created by the user or
from the auto-upgrade functionality
 - Updated badging on table to show retry attempts
- Added `manage auto-upgrade` button to agent activity actions created
by automatic upgrades
- Updated check in `automatic_upgrade_task` to only consider active
agents in order to resolve an issue where uninstalled agents could
affect the upgrade of new ones.
- Reworked rounding functionality when percentages or counts of agents
to upgrade were over or under where they should be. Rounding is now done
in a way such that no agents get left behind, and we dont try to upgrade
more than exist.
- Added new test coverage for the rounding functionality, the new active
vs inactive agents check, as well as ensuring the manage auto-upgrades
button always renders.

Simplified UI with tooltip and button to quickly access auto-upgrade
settings for the policy the action belongs to:

![image](https://github.com/user-attachments/assets/44205322-d6ca-40fb-bfb3-c1f26132418b)

Updated tooltip to let the user know that rounding is in place:

![image](https://github.com/user-attachments/assets/0d62688e-6d48-4c0a-9b03-a77deb814f1e)

### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [ ] 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)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [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
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This was checked for breaking HTTP API changes, and any breaking
changes have been approved by the breaking-change committee. The
`release_note:breaking` label should be applied in these situations.
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

### Identify risks

N/A

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Mason Herron 2025-04-07 09:31:34 -06:00 committed by GitHub
parent b920c645c2
commit a46e8114a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 558 additions and 169 deletions

View file

@ -19679,6 +19679,9 @@
"hasRolloutPeriod": {
"type": "boolean"
},
"is_automatic": {
"type": "boolean"
},
"latestErrors": {
"items": {
"additionalProperties": false,

View file

@ -19679,6 +19679,9 @@
"hasRolloutPeriod": {
"type": "boolean"
},
"is_automatic": {
"type": "boolean"
},
"latestErrors": {
"items": {
"additionalProperties": false,

View file

@ -22994,6 +22994,8 @@ paths:
type: string
hasRolloutPeriod:
type: boolean
is_automatic:
type: boolean
latestErrors:
items:
additionalProperties: false

View file

@ -25231,6 +25231,8 @@ paths:
type: string
hasRolloutPeriod:
type: boolean
is_automatic:
type: boolean
latestErrors:
items:
additionalProperties: false

View file

@ -18461,8 +18461,6 @@
"xpack.fleet.addIntegration.standaloneWarning": "La configuration des intégrations en exécutant Elastic Agent en mode autonome est une opération avancée. Si possible, nous vous conseillons d'utiliser plutôt {link}.",
"xpack.fleet.addIntegration.switchToManagedButton": "Enregistrer plutôt dans Fleet (recommandé)",
"xpack.fleet.agent.metricsNotAvailableMonitoringNotEnabled": "Le monitoring des agents n'est pas activé pour cette politique d'agent. Visitez les paramètres de politique d'agent pour activer le monitoring.",
"xpack.fleet.agentActivity.completedTitle": "{nbAgents} {agents} {completedText}{offlineText}",
"xpack.fleet.agentActivity.inProgressTitle": "{inProgressText} {nbAgents} {agents} {reassignText}{upgradeText}{failuresText}",
"xpack.fleet.agentActivity.policyChangeCompletedTitle": "Politique modifiée",
"xpack.fleet.agentActivityButton.tourContent": "Consultez ici et à tout moment l'historique des activités des agents en cours, terminées et planifiées.",
"xpack.fleet.agentActivityButton.tourTitle": "Historique des activités des agents",
@ -18492,7 +18490,6 @@
"xpack.fleet.agentActivityFlyout.startedDescription": "Démarré le {date}",
"xpack.fleet.agentActivityFlyout.title": "Activité des agents",
"xpack.fleet.agentActivityFlyout.todayTitle": "Aujourd'hui",
"xpack.fleet.agentActivityFlyout.upgradeDescription": "{guideLink} concernant les mises à jour de l'agent.",
"xpack.fleet.agentActivityFlyout.viewAgentsButton": "Afficher les agents",
"xpack.fleet.agentActivityFlyout.viewAgentsButtonDisabledMaxTooltip": "La fonctionnalité d'affichage des agents est disponible uniquement pour une action affectant moins de {agentCount} agents",
"xpack.fleet.agentActivityFlyout.viewAgentsButtonPolicyChangeTooltip": "Afficher les agents actuellement affectés à cette politique",

View file

@ -18437,8 +18437,6 @@
"xpack.fleet.addIntegration.standaloneWarning": "スタンドアロンモードでElasticエージェントを実行して統合を設定する方法は、上級者向けです。可能なかぎり、{link}を使用することをお勧めします。",
"xpack.fleet.addIntegration.switchToManagedButton": "Fleetで登録推奨",
"xpack.fleet.agent.metricsNotAvailableMonitoringNotEnabled": "このエージェントポリシーのアラート監視が無効です。エージェントポリシー設定にアクセスし、監視を有効化してください。",
"xpack.fleet.agentActivity.completedTitle": "{nbAgents} {agents} {completedText}{offlineText}",
"xpack.fleet.agentActivity.inProgressTitle": "{inProgressText} {nbAgents} {agents} {reassignText}{upgradeText}{failuresText}",
"xpack.fleet.agentActivity.policyChangeCompletedTitle": "ポリシーが変更されました",
"xpack.fleet.agentActivityButton.tourContent": "実行中、完了、スケジュール済みのエージェントアクションアクティビティ履歴をいつでもここで確認できます。",
"xpack.fleet.agentActivityButton.tourTitle": "エージェントアクティビティ履歴",
@ -18468,7 +18466,6 @@
"xpack.fleet.agentActivityFlyout.startedDescription": "{date}に開始しました。",
"xpack.fleet.agentActivityFlyout.title": "エージェントアクティビティ",
"xpack.fleet.agentActivityFlyout.todayTitle": "今日",
"xpack.fleet.agentActivityFlyout.upgradeDescription": "エージェントアップグレードについては、{guideLink}。",
"xpack.fleet.agentActivityFlyout.viewAgentsButton": "エージェントを表示",
"xpack.fleet.agentActivityFlyout.viewAgentsButtonDisabledMaxTooltip": "エージェントの表示機能は、{agentCount}個未満のエージェントに影響するアクションでのみ利用可能です",
"xpack.fleet.agentActivityFlyout.viewAgentsButtonPolicyChangeTooltip": "現在このポリシーに割り当てられているエージェントを表示",

View file

@ -18478,8 +18478,6 @@
"xpack.fleet.addIntegration.standaloneWarning": "通过在独立模式下运行 Elastic 代理来设置集成为高级选项。如果可能,我们建议改用 {link}。",
"xpack.fleet.addIntegration.switchToManagedButton": "改为在 Fleet 中注册(建议)",
"xpack.fleet.agent.metricsNotAvailableMonitoringNotEnabled": "未对此代理策略启用代理监测。访问代理策略设置以启用监测。",
"xpack.fleet.agentActivity.completedTitle": "{nbAgents} {agents} {completedText}{offlineText}",
"xpack.fleet.agentActivity.inProgressTitle": "{inProgressText} {nbAgents} {agents} {reassignText}{upgradeText}{failuresText}",
"xpack.fleet.agentActivity.policyChangeCompletedTitle": "策略已更改",
"xpack.fleet.agentActivityButton.tourContent": "随时在此处查看正在进行、已完成和已计划的代理操作活动历史记录。",
"xpack.fleet.agentActivityButton.tourTitle": "代理活动历史记录",
@ -18509,7 +18507,6 @@
"xpack.fleet.agentActivityFlyout.startedDescription": "已于 {date}启动。",
"xpack.fleet.agentActivityFlyout.title": "代理活动",
"xpack.fleet.agentActivityFlyout.todayTitle": "今日",
"xpack.fleet.agentActivityFlyout.upgradeDescription": "有关代理升级的{guideLink}。",
"xpack.fleet.agentActivityFlyout.viewAgentsButton": "查看代理",
"xpack.fleet.agentActivityFlyout.viewAgentsButtonDisabledMaxTooltip": "查看代理功能仅适用于影响的代理数不超过 {agentCount} 个的操作",
"xpack.fleet.agentActivityFlyout.viewAgentsButtonPolicyChangeTooltip": "查看当前分配给此策略的代理",

View file

@ -73,6 +73,7 @@ export interface NewAgentAction {
source_uri?: string;
total?: number;
is_automatic?: boolean;
policyId?: string;
}
export interface AgentAction extends NewAgentAction {
@ -187,6 +188,7 @@ export interface ActionStatus {
latestErrors?: ActionErrorResult[];
revision?: number;
policyId?: string;
is_automatic?: boolean;
}
export interface AgentDiagnostics {
@ -462,6 +464,9 @@ export interface FleetServerAgentAction {
*/
is_automatic?: boolean;
// the id of the policy associated with the action
policyId?: string;
[k: string]: unknown;
}

View file

@ -17,7 +17,10 @@ import {
EuiPanel,
EuiSpacer,
useEuiTheme,
EuiIconTip,
EuiButtonEmpty,
} from '@elastic/eui';
import styled from '@emotion/styled';
import type { ActionStatus } from '../../../../../types';
@ -26,12 +29,23 @@ import { ViewErrors } from '../view_errors';
import { formattedTime, getAction, inProgressDescription, inProgressTitle } from './helpers';
import { ViewAgentsButton } from './view_agents_button';
const Divider = styled.div`
width: 0;
height: 50%;
border-left: ${(props) => props.theme.euiTheme.border.thin};
position: relative;
top: 50%;
transform: translateY(-50%);
`;
export const ActivityItem: React.FunctionComponent<{
action: ActionStatus;
onClickViewAgents: (action: ActionStatus) => void;
}> = ({ action, onClickViewAgents }) => {
onClickManageAutoUpgradeAgents: (action: ActionStatus) => void;
}> = ({ action, onClickViewAgents, onClickManageAutoUpgradeAgents }) => {
const theme = useEuiTheme();
const isAutomaticUpgrade = action.is_automatic;
const completeTitle =
action.type === 'POLICY_CHANGE' && action.nbAgentsActioned === 0 ? (
<EuiText>
@ -41,26 +55,43 @@ export const ActivityItem: React.FunctionComponent<{
/>
</EuiText>
) : (
<EuiText>
<FormattedMessage
id="xpack.fleet.agentActivity.completedTitle"
defaultMessage="{nbAgents} {agents} {completedText}{offlineText}"
values={{
nbAgents:
action.nbAgentsAck === action.nbAgentsActioned
? action.nbAgentsAck
: action.nbAgentsAck + ' of ' + action.nbAgentsActioned,
agents: action.nbAgentsActioned === 1 ? 'agent' : 'agents',
completedText: getAction(action.type, action.actionId).completedText,
offlineText:
action.status === 'ROLLOUT_PASSED' && action.nbAgentsActioned - action.nbAgentsAck > 0
? `, ${
action.nbAgentsActioned - action.nbAgentsAck
} agent(s) offline during the rollout period`
: '',
}}
/>
</EuiText>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiText>
<EuiFlexGroup gutterSize="s" alignItems="center">
<FormattedMessage
id="xpack.fleet.agentActivity.completedTitle"
defaultMessage="{nbAgents} {agents} {completedText}{versionText}{offlineText}{automaticIcon}"
values={{
nbAgents:
action.nbAgentsAck === action.nbAgentsActioned
? action.nbAgentsAck
: action.nbAgentsAck + ' of ' + action.nbAgentsActioned,
agents: action.nbAgentsActioned === 1 ? 'agent' : 'agents',
completedText: getAction(action.type, action.actionId).completedText,
offlineText:
action.status === 'ROLLOUT_PASSED' &&
action.nbAgentsActioned - action.nbAgentsAck > 0
? `, ${
action.nbAgentsActioned - action.nbAgentsAck
} agent(s) offline during the rollout period`
: '',
versionText: action.version ? ` to version ${action.version}` : '',
automaticIcon: action.is_automatic ? (
<EuiIconTip
anchorProps={{
style: { display: 'flex', alignItems: 'center' },
}}
type="timeRefresh"
content="Triggered by an automatic upgrade"
/>
) : null,
}}
/>
</EuiFlexGroup>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
// TODO: investigate whether default completion is due to a bug
@ -100,7 +131,7 @@ export const ActivityItem: React.FunctionComponent<{
} = {
IN_PROGRESS: {
icon: <EuiLoadingSpinner size="m" />,
title: <EuiText>{inProgressTitle(action)}</EuiText>,
title: <EuiText>{inProgressTitle(action, action.is_automatic)}</EuiText>,
titleColor: theme.euiTheme.colors.textPrimary,
description: <EuiText color="subdued">{inProgressDescription(action.creationTime)}</EuiText>,
},
@ -247,7 +278,31 @@ export const ActivityItem: React.FunctionComponent<{
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xs" />
<ViewAgentsButton action={action} onClickViewAgents={onClickViewAgents} />
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<ViewAgentsButton action={action} onClickViewAgents={onClickViewAgents} />
</EuiFlexItem>
{isAutomaticUpgrade && (
<>
<EuiFlexItem grow={false}>
<Divider />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="manageAutoUpgradesButton"
onClick={() => onClickManageAutoUpgradeAgents(action)}
size="m"
>
<FormattedMessage
id="xpack.fleet.agentActivityFlyout.manageAutoUpgradeAgents"
defaultMessage="Manage auto-upgrade agents"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</>
)}
</EuiFlexGroup>
</EuiPanel>
);
};

View file

@ -7,7 +7,7 @@
import type { ReactNode } from 'react';
import React from 'react';
import { EuiText, EuiPanel } from '@elastic/eui';
import { EuiText, EuiPanel, EuiHealth, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import type { ActionStatus } from '../../../../../types';
@ -19,12 +19,22 @@ export const ActivitySection: React.FunctionComponent<{
actions: ActionStatus[];
abortUpgrade: (action: ActionStatus) => Promise<void>;
onClickViewAgents: (action: ActionStatus) => void;
}> = ({ title, actions, abortUpgrade, onClickViewAgents }) => {
onClickManageAutoUpgradeAgents: (action: ActionStatus) => void;
}> = ({ title, actions, abortUpgrade, onClickViewAgents, onClickManageAutoUpgradeAgents }) => {
return (
<>
<EuiPanel color="subdued" hasBorder={true} borderRadius="none">
<EuiText>
<b>{title}</b>
<EuiFlexGroup gutterSize="xs" alignItems="center">
{actions.some((action) => action.status === 'IN_PROGRESS') && (
<EuiFlexItem grow={false}>
<EuiHealth color="success" />
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<b>{title}</b>
</EuiFlexItem>
</EuiFlexGroup>
</EuiText>
</EuiPanel>
{actions.map((currentAction) =>
@ -34,12 +44,14 @@ export const ActivitySection: React.FunctionComponent<{
abortUpgrade={abortUpgrade}
key={currentAction.actionId}
onClickViewAgents={onClickViewAgents}
onClickManageAutoUpgradeAgents={onClickManageAutoUpgradeAgents}
/>
) : (
<ActivityItem
action={currentAction}
key={currentAction.actionId}
onClickViewAgents={onClickViewAgents}
onClickManageAutoUpgradeAgents={onClickManageAutoUpgradeAgents}
/>
)
)}

View file

@ -47,6 +47,8 @@ export const FlyoutBody: React.FunctionComponent<{
currentActions: ActionStatus[];
abortUpgrade: (action: ActionStatus) => Promise<void>;
onClickViewAgents: (action: ActionStatus) => Promise<void>;
onClickManageAutoUpgradeAgents: (action: ActionStatus) => void;
areActionsFullyLoaded: boolean;
onClickShowMore: () => void;
dateFilter: moment.Moment | null;
@ -56,6 +58,7 @@ export const FlyoutBody: React.FunctionComponent<{
currentActions,
abortUpgrade,
onClickViewAgents,
onClickManageAutoUpgradeAgents,
areActionsFullyLoaded,
onClickShowMore,
dateFilter,
@ -140,7 +143,9 @@ export const FlyoutBody: React.FunctionComponent<{
}
// Loaded actions
const inProgressActions = currentActions.filter((a) => a.status === 'IN_PROGRESS');
const completedActions = currentActions.filter((a) => a.status !== 'IN_PROGRESS');
const todayActions = getTodayActions(completedActions);
const otherDays = getOtherDaysActions(completedActions);
@ -162,6 +167,7 @@ export const FlyoutBody: React.FunctionComponent<{
actions={inProgressActions}
abortUpgrade={abortUpgrade}
onClickViewAgents={onClickViewAgents}
onClickManageAutoUpgradeAgents={onClickManageAutoUpgradeAgents}
/>
) : null}
{todayActions.length > 0 ? (
@ -175,6 +181,7 @@ export const FlyoutBody: React.FunctionComponent<{
actions={todayActions}
abortUpgrade={abortUpgrade}
onClickViewAgents={onClickViewAgents}
onClickManageAutoUpgradeAgents={onClickManageAutoUpgradeAgents}
/>
) : null}
{Object.keys(otherDays).map((day) => (
@ -184,6 +191,7 @@ export const FlyoutBody: React.FunctionComponent<{
actions={otherDays[day]}
abortUpgrade={abortUpgrade}
onClickViewAgents={onClickViewAgents}
onClickManageAutoUpgradeAgents={onClickManageAutoUpgradeAgents}
/>
))}
</EuiFlexGroup>

View file

@ -7,6 +7,7 @@
import React from 'react';
import { FormattedDate, FormattedMessage, FormattedTime } from '@kbn/i18n-react';
import { EuiIconTip, EuiFlexGroup } from '@elastic/eui';
import type { ActionStatus } from '../../../../../types';
@ -71,25 +72,36 @@ export const getAction = (type?: string, actionId?: string) => {
return actionNames[type ?? 'ACTION'] ?? actionNames.ACTION;
};
export const inProgressTitle = (action: ActionStatus) => (
<FormattedMessage
id="xpack.fleet.agentActivity.inProgressTitle"
defaultMessage="{inProgressText} {nbAgents} {agents} {reassignText}{upgradeText}{failuresText}"
values={{
nbAgents:
action.nbAgentsAck >= action.nbAgentsActioned
? action.nbAgentsAck
: action.nbAgentsAck === 0
? action.nbAgentsActioned
: action.nbAgentsActioned - action.nbAgentsAck + ' of ' + action.nbAgentsActioned,
agents: action.nbAgentsActioned === 1 ? 'agent' : 'agents',
inProgressText: getAction(action.type, action.actionId).inProgressText,
reassignText:
action.type === 'POLICY_REASSIGN' && action.newPolicyId ? `to ${action.newPolicyId}` : '',
upgradeText: action.type === 'UPGRADE' ? `to version ${action.version}` : '',
failuresText: action.nbAgentsFailed > 0 ? `, has ${action.nbAgentsFailed} failure(s)` : '',
}}
/>
export const inProgressTitle = (action: ActionStatus, isAutomatic: boolean | undefined) => (
<EuiFlexGroup gutterSize="s" alignItems="center">
<FormattedMessage
id="xpack.fleet.agentActivity.inProgressTitle"
defaultMessage="{inProgressText} {nbAgents} {agents}{reassignText}{upgradeText}{failuresText}{automaticIcon}"
values={{
nbAgents:
action.nbAgentsAck >= action.nbAgentsActioned
? action.nbAgentsAck
: action.nbAgentsAck === 0
? action.nbAgentsActioned
: action.nbAgentsActioned - action.nbAgentsAck + ' of ' + action.nbAgentsActioned,
agents: action.nbAgentsActioned === 1 ? 'agent' : 'agents',
inProgressText: getAction(action.type, action.actionId).inProgressText,
reassignText:
action.type === 'POLICY_REASSIGN' && action.newPolicyId ? `to ${action.newPolicyId}` : '',
upgradeText: action.type === 'UPGRADE' ? ` to version ${action.version}` : '',
failuresText: action.nbAgentsFailed > 0 ? `, has ${action.nbAgentsFailed} failure(s)` : '',
automaticIcon: isAutomatic ? (
<EuiIconTip
anchorProps={{
style: { display: 'flex', alignItems: 'center' },
}}
type="timeRefresh"
content="Triggered by an automatic upgrade"
/>
) : null,
}}
/>
</EuiFlexGroup>
);
export const inProgressDescription = (time?: string) => (

View file

@ -6,17 +6,21 @@
*/
import React from 'react';
import { act, render, fireEvent } from '@testing-library/react';
import { act, fireEvent } from '@testing-library/react';
import type { TestRenderer } from '../../../../../../../mock';
import { createFleetTestRendererMock } from '../../../../../../../mock';
// eslint-disable-next-line @kbn/eslint/module_migration
import { IntlProvider } from 'react-intl';
import { useActionStatus } from '../../hooks';
import { useGetAgentPolicies, useStartServices, useAuthz } from '../../../../../hooks';
import { useGetAgentPolicies, useAuthz } from '../../../../../hooks';
import { AgentActivityFlyout } from '.';
jest.mock('../../hooks');
jest.mock('../../../../../hooks');
jest.mock('../../../../../../../hooks/use_request/agent_policy');
jest.mock('../../../../../../../hooks/use_authz');
jest.mock('@kbn/shared-ux-link-redirect-app', () => ({
RedirectAppLinks: ({ children }: { children: React.ReactNode }) => children,
@ -24,7 +28,7 @@ jest.mock('@kbn/shared-ux-link-redirect-app', () => ({
const mockUseActionStatus = useActionStatus as jest.Mock;
const mockUseGetAgentPolicies = useGetAgentPolicies as jest.Mock;
const mockUseStartServices = useStartServices as jest.Mock;
const mockedUseAuthz = useAuthz as jest.Mock;
jest.mock('@kbn/logs-shared-plugin/common', () => {
@ -43,6 +47,7 @@ describe('AgentActivityFlyout', () => {
const mockAbortUpgrade = jest.fn();
const mockSetSearch = jest.fn();
const mockSetSelectedStatus = jest.fn();
const mockOpenManageAutoUpgradeModal = jest.fn();
const component = (refreshAgentActivity: boolean = false) => (
<IntlProvider timeZone="UTC" locale="en">
@ -52,11 +57,13 @@ describe('AgentActivityFlyout', () => {
refreshAgentActivity={refreshAgentActivity}
setSearch={mockSetSearch}
setSelectedStatus={mockSetSelectedStatus}
openManageAutoUpgradeModal={mockOpenManageAutoUpgradeModal}
/>
</IntlProvider>
);
let testRenderer: TestRenderer;
beforeEach(() => {
testRenderer = createFleetTestRendererMock();
mockOnClose.mockReset();
mockOnAbortSuccess.mockReset();
mockAbortUpgrade.mockReset();
@ -66,30 +73,18 @@ describe('AgentActivityFlyout', () => {
mockUseGetAgentPolicies.mockReturnValue({
data: {
items: [
{ id: 'policy1', name: 'Policy 1' },
{ id: 'policy2', name: 'Policy 2' },
{ id: 'Policy1', name: 'Policy 1' },
{ id: 'Policy2', name: 'Policy 2' },
],
},
});
mockUseStartServices.mockReturnValue({
docLinks: { links: { fleet: { upgradeElasticAgent: 'https://elastic.co' } } },
application: { navigateToUrl: jest.fn() },
http: { basePath: { prepend: jest.fn() } },
share: {
url: {
locators: {
get: () => ({
useUrl: () => 'https://locator.url',
}),
},
},
},
});
mockedUseAuthz.mockReturnValue({
fleet: {
readAgents: true,
allAgents: true,
},
integrations: {},
} as any);
});
@ -118,24 +113,22 @@ describe('AgentActivityFlyout', () => {
hasRolloutPeriod: true,
},
];
mockUseActionStatus
.mockReturnValueOnce({
currentActions: mockActionStatuses,
abortUpgrade: mockAbortUpgrade,
isFirstLoading: true,
})
.mockReturnValueOnce({
currentActions: mockActionStatuses,
abortUpgrade: mockAbortUpgrade,
isFirstLoading: false,
});
mockUseActionStatus.mockReturnValue({
currentActions: mockActionStatuses,
abortUpgrade: mockAbortUpgrade,
isFirstLoading: true,
});
const result = render(component());
const result = testRenderer.render(component());
expect(result.getByText('Agent activity')).toBeInTheDocument();
expect(result.queryByTestId('loading')).toBeInTheDocument();
expect(result.queryByTestId('upgradeInProgressTitle')).not.toBeInTheDocument();
mockUseActionStatus.mockReturnValue({
currentActions: mockActionStatuses,
abortUpgrade: mockAbortUpgrade,
isFirstLoading: false,
});
result.rerender(component());
expect(result.queryByTestId('loading')).not.toBeInTheDocument();
expect(result.queryByTestId('upgradeInProgressTitle')).toBeInTheDocument();
@ -147,7 +140,7 @@ describe('AgentActivityFlyout', () => {
abortUpgrade: mockAbortUpgrade,
isFirstLoading: false,
});
const result = render(component());
const result = testRenderer.render(component());
expect(result.queryByText('No activity to display')).toBeInTheDocument();
});
@ -174,7 +167,7 @@ describe('AgentActivityFlyout', () => {
abortUpgrade: mockAbortUpgrade,
isFirstLoading: false,
});
const result = render(component());
const result = testRenderer.render(component());
expect(result.getByText('Agent activity')).toBeInTheDocument();
@ -186,7 +179,7 @@ describe('AgentActivityFlyout', () => {
result.container
.querySelector('[data-test-subj="upgradeInProgressDescription"]')!
.textContent?.replace(/\s/g, '')
).toContain('Started on Sep 15, 2022 10:00 AM. Learn more'.replace(/\s/g, ''));
).toContain('Started on Sep 15, 2022 10:00 AM.'.replace(/\s/g, ''));
act(() => {
fireEvent.click(result.getByText('Cancel'));
@ -217,7 +210,7 @@ describe('AgentActivityFlyout', () => {
abortUpgrade: mockAbortUpgrade,
isFirstLoading: false,
});
const result = render(component());
const result = testRenderer.render(component());
expect(result.getByText('Agent activity')).toBeInTheDocument();
@ -229,7 +222,7 @@ describe('AgentActivityFlyout', () => {
result.container
.querySelector('[data-test-subj="upgradeInProgressDescription"]')!
.textContent?.replace(/\s/g, '')
).toContain('Started on Sep 15, 2022 10:00 AM. Learn more'.replace(/\s/g, ''));
).toContain('Started on Sep 15, 2022 10:00 AM.'.replace(/\s/g, ''));
expect(result.queryByText('Cancel')).not.toBeInTheDocument();
});
@ -255,7 +248,7 @@ describe('AgentActivityFlyout', () => {
abortUpgrade: mockAbortUpgrade,
isFirstLoading: false,
});
const result = render(component());
const result = testRenderer.render(component());
expect(result.getByText('Agent activity')).toBeInTheDocument();
@ -266,7 +259,7 @@ describe('AgentActivityFlyout', () => {
result.container
.querySelector('[data-test-subj="upgradeInProgressDescription"]')!
.textContent?.replace(/\s/g, '')
).toContain('Scheduled for Sep 16, 2022 10:00 AM. Learn more'.replace(/\s/g, ''));
).toContain('Scheduled for Sep 16, 2022 10:00 AM.'.replace(/\s/g, ''));
act(() => {
fireEvent.click(result.getByText('Cancel'));
@ -295,7 +288,7 @@ describe('AgentActivityFlyout', () => {
abortUpgrade: mockAbortUpgrade,
isFirstLoading: false,
});
const result = render(component());
const result = testRenderer.render(component());
expect(result.container.querySelector('[data-test-subj="statusTitle"]')!.textContent).toEqual(
'2 agents upgraded'
@ -326,7 +319,7 @@ describe('AgentActivityFlyout', () => {
abortUpgrade: mockAbortUpgrade,
isFirstLoading: false,
});
const result = render(component());
const result = testRenderer.render(component());
expect(result.container.querySelector('[data-test-subj="statusTitle"]')!.textContent).toEqual(
'1 of 2 agents upgraded, 1 agent(s) offline during the rollout period'
@ -357,7 +350,7 @@ describe('AgentActivityFlyout', () => {
abortUpgrade: mockAbortUpgrade,
isFirstLoading: false,
});
const result = render(component());
const result = testRenderer.render(component());
expect(result.container.querySelector('[data-test-subj="statusTitle"]')!.textContent).toEqual(
'1 of 2 agents upgraded, 1 agent(s) offline during the rollout period'
@ -393,7 +386,7 @@ describe('AgentActivityFlyout', () => {
abortUpgrade: mockAbortUpgrade,
isFirstLoading: false,
});
const result = render(component());
const result = testRenderer.render(component());
expect(result.container.querySelector('[data-test-subj="statusTitle"]')!.textContent).toEqual(
'Agent unenrollment expired'
@ -426,7 +419,7 @@ describe('AgentActivityFlyout', () => {
abortUpgrade: mockAbortUpgrade,
isFirstLoading: false,
});
const result = render(component());
const result = testRenderer.render(component());
expect(result.container.querySelector('[data-test-subj="statusTitle"]')!.textContent).toEqual(
'Agent upgrade cancelled'
@ -448,7 +441,7 @@ describe('AgentActivityFlyout', () => {
nbAgentsActioned: 1,
status: 'FAILED',
expiration: '2099-09-16T10:00:00.000Z',
newPolicyId: 'policy1',
newPolicyId: 'Policy1',
creationTime: '2022-09-15T10:00:00.000Z',
nbAgentsFailed: 1,
completionTime: '2022-09-15T11:00:00.000Z',
@ -459,7 +452,7 @@ describe('AgentActivityFlyout', () => {
abortUpgrade: mockAbortUpgrade,
isFirstLoading: false,
});
const result = render(component());
const result = testRenderer.render(component());
expect(result.container.querySelector('[data-test-subj="statusTitle"]')!.textContent).toEqual(
'0 of 1 agent assigned to a new policy'
@ -496,7 +489,7 @@ describe('AgentActivityFlyout', () => {
abortUpgrade: mockAbortUpgrade,
isFirstLoading: false,
});
const result = render(component());
const result = testRenderer.render(component());
expect(result.container.querySelector('[data-test-subj="statusTitle"]')!.textContent).toEqual(
'0 of 3 agents actioned'
@ -518,7 +511,7 @@ describe('AgentActivityFlyout', () => {
nbAgentsActioned: 0,
status: 'COMPLETE',
expiration: '2099-09-16T10:00:00.000Z',
policyId: 'policy1',
policyId: 'Policy1',
revision: 2,
creationTime: '2022-09-15T10:00:00.000Z',
nbAgentsFailed: 0,
@ -530,7 +523,7 @@ describe('AgentActivityFlyout', () => {
abortUpgrade: mockAbortUpgrade,
isFirstLoading: false,
});
const result = render(component());
const result = testRenderer.render(component());
expect(result.container.querySelector('[data-test-subj="statusTitle"]')!.textContent).toEqual(
'Policy changed'
@ -552,7 +545,7 @@ describe('AgentActivityFlyout', () => {
nbAgentsActioned: 3,
status: 'COMPLETE',
expiration: '2099-09-16T10:00:00.000Z',
policyId: 'policy1',
policyId: 'Policy1',
revision: 2,
creationTime: '2022-09-15T10:00:00.000Z',
nbAgentsFailed: 0,
@ -564,7 +557,7 @@ describe('AgentActivityFlyout', () => {
abortUpgrade: mockAbortUpgrade,
isFirstLoading: false,
});
const result = render(component());
const result = testRenderer.render(component());
expect(result.container.querySelector('[data-test-subj="statusTitle"]')!.textContent).toEqual(
'3 agents applied policy change'
@ -576,6 +569,42 @@ describe('AgentActivityFlyout', () => {
).toContain('Policy1 changed to revision 2 at Sep 15, 2022 10:00 AM.'.replace(/\s/g, ''));
});
it('should render agent activity for an automatic upgrade', () => {
const mockActionStatuses = [
{
actionId: 'action8',
nbAgentsActionCreated: 3,
nbAgentsAck: 0,
type: 'UPGRADE',
nbAgentsActioned: 3,
is_automatic: true,
status: 'IN_PROGRESS',
expiration: '2099-09-16T10:00:00.000Z',
policyId: 'Policy1',
revision: 2,
creationTime: '2022-09-15T10:00:00.000Z',
nbAgentsFailed: 0,
completionTime: '2022-09-15T11:00:00.000Z',
version: '8.17.3',
},
];
mockUseActionStatus.mockReturnValue({
currentActions: mockActionStatuses,
abortUpgrade: mockAbortUpgrade,
isFirstLoading: false,
});
const result = testRenderer.render(component());
expect(
result.container.querySelector('[data-test-subj="upgradeInProgressTitle"]')!.textContent
).toContain('Upgrading 3 agents to version 8.17.3');
expect(
result.container
.querySelector('[data-test-subj="manageAutoUpgradesButton"]')!
.textContent?.replace(/\s/g, '')
).toContain('Manage auto-upgrade agents'.replace(/\s/g, ''));
});
it('should keep flyout state on new data', () => {
const failedAction = {
actionId: 'action1',
@ -626,7 +655,7 @@ describe('AgentActivityFlyout', () => {
};
}
});
const result = render(component());
const result = testRenderer.render(component());
expect(result.getByText('Agent activity')).toBeInTheDocument();
expect(result.container.querySelector('[data-test-subj="upgradeInProgressTitle"]')).toBe(null);

View file

@ -46,7 +46,15 @@ export const AgentActivityFlyout: React.FunctionComponent<{
refreshAgentActivity: boolean;
setSearch: (search: string) => void;
setSelectedStatus: (status: string[]) => void;
}> = ({ onClose, onAbortSuccess, refreshAgentActivity, setSearch, setSelectedStatus }) => {
openManageAutoUpgradeModal: (policyId: string) => void;
}> = ({
onClose,
onAbortSuccess,
refreshAgentActivity,
setSearch,
setSelectedStatus,
openManageAutoUpgradeModal,
}) => {
const { notifications } = useStartServices();
const { data: agentPoliciesData } = useGetAgentPolicies({
perPage: SO_SEARCH_LIMIT,
@ -75,7 +83,7 @@ export const AgentActivityFlyout: React.FunctionComponent<{
currentActions.map((a) => ({
...a,
newPolicyId: getAgentPolicyName(a.newPolicyId ?? ''),
policyId: getAgentPolicyName(a.policyId ?? ''),
policyId: a.policyId ? a.policyId : getAgentPolicyName(a.newPolicyId ?? ''),
})),
[currentActions, getAgentPolicyName]
);
@ -100,6 +108,11 @@ export const AgentActivityFlyout: React.FunctionComponent<{
});
}
};
const onClickManageAutoUpgradeAgents = async (action: ActionStatus) => {
// use the policy id from the action to manage
onClose();
openManageAutoUpgradeModal(action.policyId!);
};
const onClickShowMore = () => {
setNActions(nActions + 10);
@ -153,6 +166,7 @@ export const AgentActivityFlyout: React.FunctionComponent<{
currentActions={currentActionsEnriched}
abortUpgrade={abortUpgrade}
onClickViewAgents={onClickViewAgents}
onClickManageAutoUpgradeAgents={onClickManageAutoUpgradeAgents}
areActionsFullyLoaded={areActionsFullyLoaded}
onClickShowMore={onClickShowMore}
dateFilter={dateFilter}

View file

@ -16,24 +16,37 @@ import {
EuiButton,
EuiLink,
useEuiTheme,
EuiButtonEmpty,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import styled from '@emotion/styled';
import type { ActionStatus } from '../../../../../types';
import { useStartServices } from '../../../../../hooks';
import { formattedTime, inProgressDescription, inProgressTitle } from './helpers';
import { ViewAgentsButton } from './view_agents_button';
const Divider = styled.div`
width: 0;
height: 50%;
border-left: ${(props) => props.theme.euiTheme.border.thin};
position: relative;
top: 50%;
transform: translateY(-50%);
`;
export const UpgradeInProgressActivityItem: React.FunctionComponent<{
action: ActionStatus;
abortUpgrade: (action: ActionStatus) => Promise<void>;
onClickViewAgents: (action: ActionStatus) => void;
}> = ({ action, abortUpgrade, onClickViewAgents }) => {
onClickManageAutoUpgradeAgents: (action: ActionStatus) => void;
}> = ({ action, abortUpgrade, onClickViewAgents, onClickManageAutoUpgradeAgents }) => {
const { docLinks } = useStartServices();
const theme = useEuiTheme();
const isAutomaticUpgrade = action.is_automatic;
const [isAborting, setIsAborting] = useState(false);
const onClickAbortUpgrade = useCallback(async () => {
try {
@ -61,6 +74,23 @@ export const UpgradeInProgressActivityItem: React.FunctionComponent<{
return (
<EuiPanel hasBorder={true} borderRadius="none">
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexItem>
<EuiText color="subdued" data-test-subj="upgradeInProgressDescription">
<p>
{isScheduled && action.startTime ? (
<>
<FormattedMessage
id="xpack.fleet.agentActivityFlyout.scheduledDescription"
defaultMessage="Scheduled for "
/>
<strong>{formattedTime(action.startTime)}</strong>.&nbsp;
</>
) : (
<>{inProgressDescription(action.creationTime)}&nbsp;</>
)}
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup direction="row" gutterSize="m" alignItems="center">
<EuiFlexItem grow={false}>
@ -81,7 +111,7 @@ export const UpgradeInProgressActivityItem: React.FunctionComponent<{
}}
/>
) : (
inProgressTitle(action)
inProgressTitle(action, isAutomaticUpgrade)
)}
</EuiText>
</EuiFlexItem>
@ -89,40 +119,45 @@ export const UpgradeInProgressActivityItem: React.FunctionComponent<{
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup direction="column" alignItems="flexStart">
<EuiFlexItem>
<EuiText color="subdued" data-test-subj="upgradeInProgressDescription">
<p>
{isScheduled && action.startTime ? (
<>
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem grow={false}>
<ViewAgentsButton action={action} onClickViewAgents={onClickViewAgents} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<Divider />
</EuiFlexItem>
{isAutomaticUpgrade && (
<>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="manageAutoUpgradesButton"
onClick={() => onClickManageAutoUpgradeAgents(action)}
size="m"
>
<FormattedMessage
id="xpack.fleet.agentActivityFlyout.scheduledDescription"
defaultMessage="Scheduled for "
id="xpack.fleet.agentActivityFlyout.manageAutoUpgradeAgents"
defaultMessage="Manage auto-upgrade agents"
/>
<strong>{formattedTime(action.startTime)}</strong>.&nbsp;
</>
) : (
<>{inProgressDescription(action.creationTime)}&nbsp;</>
)}
<FormattedMessage
id="xpack.fleet.agentActivityFlyout.upgradeDescription"
defaultMessage="{guideLink} about agent upgrades."
values={{
guideLink: (
<EuiLink href={docLinks.links.fleet.upgradeElasticAgent} target="_blank">
<FormattedMessage
id="xpack.fleet.agentActivityFlyout.guideLink"
defaultMessage="Learn more"
/>
</EuiLink>
),
}}
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ViewAgentsButton action={action} onClickViewAgents={onClickViewAgents} />
</EuiFlexItem>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<Divider />
</EuiFlexItem>
</>
)}
<EuiFlexItem grow={false}>
<EuiButtonEmpty>
<EuiLink href={docLinks.links.fleet.upgradeElasticAgent} target="_blank">
<FormattedMessage
id="xpack.fleet.agentActivityFlyout.guideLink"
defaultMessage="Learn more"
/>
</EuiLink>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexItem grow={false}>
{showCancelButton ? (
<EuiButton

View file

@ -29,7 +29,6 @@ export const ViewAgentsButton: React.FunctionComponent<{
<EuiButtonEmpty
size="m"
onClick={() => onClickViewAgents(action)}
flush="left"
data-test-subj="agentActivityFlyout.viewAgentsButton"
disabled={isDisabled}
>

View file

@ -17,6 +17,10 @@ import {
isAgentUpgradeAvailable,
} from '../../../../../../../common/services';
import { AUTO_UPGRADE_DEFAULT_RETRIES } from '../../../../../../../common/constants';
import { useConfig } from '../../../../hooks';
/**
* Returns a user-friendly string for the estimated remaining time until the upgrade is scheduled.
*/
@ -282,7 +286,7 @@ export const AgentUpgradeStatus: React.FC<{
const status = useMemo(() => getStatusComponents(agent.upgrade_details), [agent.upgrade_details]);
const minVersion = '8.12';
const notUpgradeableMessage = getNotUpgradeableMessage(agent, latestAgentVersion);
const retryDelays = useConfig().autoUpgrades?.retryDelays ?? AUTO_UPGRADE_DEFAULT_RETRIES;
if (agent.upgrade_details && status) {
return (
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
@ -300,6 +304,28 @@ export const AgentUpgradeStatus: React.FC<{
</EuiFlexGroup>
);
}
if (agent.upgrade_attempts && agent.upgrade_attempts.length > 1 && agent.status === 'updating') {
return (
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<EuiIconTip
type="warning"
content={
<FormattedMessage
id="xpack.fleet.agentUpgradeStatusTooltip.retryingUpgrade"
defaultMessage="Retrying Upgrade ({retryCount}/{maxRetries} attempts)"
values={{
retryCount: agent.upgrade_attempts.length,
maxRetries: retryDelays.length,
}}
/>
}
color="subdued"
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
if (isAgentUpgradable && isAgentUpgradeAvailable(agent, latestAgentVersion)) {
return (

View file

@ -32,6 +32,7 @@ import {
import { useFleetServerUnhealthy } from '../hooks/use_fleet_server_unhealthy';
import { AgentRequestDiagnosticsModal } from '../components/agent_request_diagnostics_modal';
import { ManageAutoUpgradeAgentsModal } from '../components/manage_auto_upgrade_agents_modal';
import type { SelectionMode } from './components/types';
@ -62,6 +63,9 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
isOpen: false,
});
const [isAgentActivityFlyoutOpen, setAgentActivityFlyoutOpen] = useState(false);
const [isManageAutoUpgradeModalOpen, setManageAutoUpgradeModalOpen] = useState(false);
const [selectedPolicyId, setSelectedPolicyId] = useState<string | undefined>();
const flyoutContext = useFlyoutContext();
// Agent actions states
@ -285,12 +289,26 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
<AgentActivityFlyout
onAbortSuccess={fetchData}
onClose={() => setAgentActivityFlyoutOpen(false)}
openManageAutoUpgradeModal={(policyId: string) => {
setSelectedPolicyId(policyId);
setManageAutoUpgradeModalOpen(true);
}}
refreshAgentActivity={isLoading}
setSearch={setSearch}
setSelectedStatus={setSelectedStatus}
/>
</EuiPortal>
) : null}
{isManageAutoUpgradeModalOpen ? (
<EuiPortal>
<ManageAutoUpgradeAgentsModal
key={selectedPolicyId}
onClose={() => setManageAutoUpgradeModalOpen(false)}
agentPolicy={allAgentPolicies.find((p) => p.id === selectedPolicyId)!}
/>
</EuiPortal>
) : null}
{enrollmentFlyout.isOpen ? (
<EuiPortal>
<AgentEnrollmentFlyout

View file

@ -34,7 +34,7 @@ import { StatusColumn } from './status_column';
export interface ManageAutoUpgradeAgentsModalProps {
onClose: (refreshPolicy: boolean) => void;
agentPolicy: AgentPolicy;
agentCount: number;
agentCount?: number;
}
export const ManageAutoUpgradeAgentsModal: React.FunctionComponent<
@ -240,11 +240,12 @@ const TargetVersionsRow: React.FunctionComponent<{
/>
<EuiIconTip
type="iInCircle"
title={'Rounding Applied'}
content={
<FormattedMessage
data-test-subj="percentageTooltip"
id="xpack.fleet.manageAutoUpgradeAgents.percentageTooltip"
defaultMessage="Set 100 to upgrade all agents in the policy."
defaultMessage="The actual percentage of agents upgraded may vary slightly due to rounding. For example, selecting 30% of 25 agents may result in 8 agents being upgraded (32%)."
/>
}
/>

View file

@ -18,7 +18,6 @@ import {
import { FormattedMessage } from '@kbn/i18n-react';
import { useGetAutoUpgradeAgentsStatusQuery, useLink } from '../../../../../../hooks';
export const StatusColumn: React.FunctionComponent<{
agentPolicyId: string;
version: string;
@ -26,7 +25,6 @@ export const StatusColumn: React.FunctionComponent<{
}> = ({ agentPolicyId, version, percentage }) => {
const { getHref } = useLink();
const { data: autoUpgradeAgentsStatus } = useGetAutoUpgradeAgentsStatusQuery(agentPolicyId);
const getAgentsHref = useCallback(
(failed?: boolean): string => {
const kuery = failed
@ -120,7 +118,7 @@ export const StatusColumn: React.FunctionComponent<{
agentVersionCounts.agents > 0 ? (
<FormattedMessage
id="xpack.fleet.manageAutoUpgradeAgents.currentStatusTooltip"
defaultMessage="{agents} agents on target version"
defaultMessage="{agents, plural, one {# agent} other {# agents}} on target version"
values={{
agents: agentVersionCounts.agents,
}}

View file

@ -165,7 +165,6 @@ async function getActionResults(
throw err;
}
}
results.push({
...action,
nbAgentsAck: nbAgentsAck - errorCount,
@ -274,6 +273,8 @@ async function getActions(
creationTime: source['@timestamp']!,
nbAgentsFailed: 0,
hasRolloutPeriod: !!source.rollout_duration_seconds,
is_automatic: source.is_automatic,
policyId: source.policyId,
};
}

View file

@ -68,6 +68,7 @@ export async function createAgentAction(
total: newAgentAction.total,
traceparent: apm.currentTraceparent,
is_automatic: newAgentAction.is_automatic,
policyId: newAgentAction.policyId,
};
const messageSigningService = appContextService.getMessageSigningService();
@ -87,7 +88,9 @@ export async function createAgentAction(
});
auditLoggingService.writeCustomAuditLog({
message: `User created Fleet action [id=${actionId}]`,
message: `${
newAgentAction.is_automatic ? 'Automatic Upgrade' : 'User'
} created Fleet action [id=${actionId}]`,
});
return {

View file

@ -194,6 +194,7 @@ export async function upgradeBatch(
...rollingUpgradeOptions,
namespaces,
is_automatic: options.isAutomatic,
policyId: agentsToUpdate[0]?.policy_id,
});
await createErrorActionResults(

View file

@ -216,7 +216,7 @@ describe('AutomaticAgentUpgradeTask', () => {
expect.anything(),
expect.anything(),
{
agents: agents.slice(0, 2),
agents: agents.slice(0, 2), // As theres already one upgrading, and 30% of 11 is 3, we only want two items to be sent for upgrade
version: '8.18.0',
}
);
@ -260,6 +260,89 @@ describe('AutomaticAgentUpgradeTask', () => {
expect(mockedSendAutomaticUpgradeAgentsActions).not.toHaveBeenCalled();
});
it('should not take inactive agents into account when checking for upgrade eligibility', async () => {
const activeAgents = generateAgents(10, 'agent-policy-1', '8.15.0');
const uninstalledAgents = generateAgents(5, 'agent-policy-1', '8.17.3', 'uninstalled');
mockedGetAgentsByKuery
.mockResolvedValueOnce({ total: activeAgents.length } as any) // active agents
.mockResolvedValueOnce({ total: 0 } as any); // agents on or updating to target version
mockedFetchAllAgentsByKuery
.mockResolvedValueOnce(getMockFetchAllAgentsByKuery([])) // agents marked for retry
.mockResolvedValueOnce(getMockFetchAllAgentsByKuery(activeAgents)); // active agents
await runTask();
expect(mockedSendAutomaticUpgradeAgentsActions).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
{
agents: activeAgents.slice(0, 3),
version: '8.18.0',
}
);
expect(mockedSendAutomaticUpgradeAgentsActions).not.toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
{ agents: uninstalledAgents, version: '8.18.0' }
);
});
it('should correctly round agent counts and not over or undershoot the target', async () => {
// need to check that things are being rounded correctly. Each sub-array would be an automatic upgrade on a policy with a breakout of how many of each version should be upgraded
// the target percentage is what we want to hit, but the count is what we have with the first step of normal rounding
// this tests that the adjustment to ensure we dont go over or under the total amount of agents is working as intended
const MOCK_VERSIONS_AND_COUNTS = [
[
{ version: '8.17.3', count: 1, targetPercentage: 33, alreadyUpgrading: 0 }, // 3 way split of 4 agents
{ version: '8.17.2', count: 1, targetPercentage: 33, alreadyUpgrading: 0 },
{ version: '8.17.1', count: 1, targetPercentage: 34, alreadyUpgrading: 0 },
],
[
{ version: '8.17.3', count: 33, targetPercentage: 33, alreadyUpgrading: 0 }, // 3 way split of 99 agents
{ version: '8.17.2', count: 33, targetPercentage: 33, alreadyUpgrading: 0 },
{ version: '8.17.1', count: 34, targetPercentage: 34, alreadyUpgrading: 0 },
],
[{ version: '8.17.3', count: 60, targetPercentage: 60, alreadyUpgrading: 0 }], // 60% with 99 agents
[
{ version: '8.17.3', count: 13, targetPercentage: 50, alreadyUpgrading: 0 },
{ version: '8.17.2', count: 13, targetPercentage: 50, alreadyUpgrading: 0 }, // 50% each with 25 agents
],
];
const TOTAL_AGENTS_MOCKS = [4, 99, 99, 25]; // how many total agents each array should be using to recalculate the values
const MOCK_VERSIONS_AND_COUNTS_EXPECTED = [
[
{ version: '8.17.3', count: 2, targetPercentage: 33, alreadyUpgrading: 0 }, // since we were missing one
{ version: '8.17.2', count: 1, targetPercentage: 33, alreadyUpgrading: 0 }, // we should add one to the lowest
{ version: '8.17.1', count: 1, targetPercentage: 34, alreadyUpgrading: 0 },
],
[
{ version: '8.17.3', count: 32, targetPercentage: 33, alreadyUpgrading: 0 },
{ version: '8.17.2', count: 33, targetPercentage: 33, alreadyUpgrading: 0 },
{ version: '8.17.1', count: 34, targetPercentage: 34, alreadyUpgrading: 0 },
],
[{ version: '8.17.3', count: 59, targetPercentage: 60, alreadyUpgrading: 0 }], // since the 60 above was over, this should come out to 59
[
{ version: '8.17.3', count: 12, targetPercentage: 50, alreadyUpgrading: 0 }, // as theres too many above, it should get reduced by 1
{ version: '8.17.2', count: 13, targetPercentage: 50, alreadyUpgrading: 0 },
],
];
// now assert on each item using the above mocks
for (let i = 0; i < MOCK_VERSIONS_AND_COUNTS.length; i++) {
const result = await mockTask.adjustAgentCounts(
MOCK_VERSIONS_AND_COUNTS[i],
TOTAL_AGENTS_MOCKS[i]
);
expect(result).toEqual(MOCK_VERSIONS_AND_COUNTS_EXPECTED[i]);
}
});
it('Should set a rollout duration for upgrade batches bigger than 10 agents', async () => {
const agents = generateAgents(100);
mockedGetAgentsByKuery

View file

@ -61,6 +61,12 @@ interface AutomaticAgentUpgradeTaskSetupContract {
interface AutomaticAgentUpgradeTaskStartContract {
taskManager: TaskManagerStartContract;
}
interface UpgradeTargetForVersion {
version: string;
count: number;
targetPercentage: number;
alreadyUpgrading: number;
}
export class AutomaticAgentUpgradeTask {
private logger: Logger;
@ -220,6 +226,13 @@ export class AutomaticAgentUpgradeTask {
);
return;
}
// Before processing each required version, we need to get the count of agents for each version so we know if we should round some up or down to make sure we arent overshooting the total number of agents.
const versionAndCounts = await this.getVersionAndCounts(
agentPolicy,
totalActiveAgents,
esClient,
soClient
);
for (const requiredVersion of agentPolicy.required_versions ?? []) {
await this.processRequiredVersion(
@ -227,10 +240,90 @@ export class AutomaticAgentUpgradeTask {
soClient,
agentPolicy,
requiredVersion,
totalActiveAgents
versionAndCounts
);
}
}
public async getVersionAndCounts(
agentPolicy: AgentPolicy,
totalActiveAgents: number,
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract
) {
let versionAndCounts: UpgradeTargetForVersion[] = [];
for (const requiredVersion of agentPolicy.required_versions ?? []) {
let numberOfAgentsForUpgrade = Math.round(
(totalActiveAgents * requiredVersion.percentage) / 100
);
// Subtract the total number of agents already or on or updating to target version.
const updatingToKuery = `(upgrade_details.target_version:${requiredVersion.version} AND NOT upgrade_details.state:UPG_FAILED)`;
const totalOnOrUpdatingToTargetVersionAgents = await this.getAgentCount(
esClient,
soClient,
`((policy_id:${agentPolicy.id} AND agent.version:${
requiredVersion.version
}) OR ${updatingToKuery}) AND ${AgentStatusKueryHelper.buildKueryForActiveAgents()}`
);
numberOfAgentsForUpgrade -= totalOnOrUpdatingToTargetVersionAgents;
versionAndCounts.push({
version: requiredVersion.version,
count: numberOfAgentsForUpgrade,
targetPercentage: requiredVersion.percentage,
alreadyUpgrading: totalOnOrUpdatingToTargetVersionAgents,
});
}
// Then we need to make adjustments based on the total to make sure we arent over or undershooting the total number of agents
versionAndCounts = await this.adjustAgentCounts(versionAndCounts, totalActiveAgents);
return versionAndCounts;
}
public async adjustAgentCounts(
versionAndCounts: UpgradeTargetForVersion[],
totalActiveAgents: number
) {
// Calculate what we actually have vs what we need to have.
// First we need to get the total actual percentage if we actually added the new agents and considering the existing ones
const totalActualPercentage =
((versionAndCounts.reduce((acc, item) => acc + item.count, 0) +
versionAndCounts.reduce((acc, item) => acc + item.alreadyUpgrading, 0)) /
totalActiveAgents) *
100;
const totalNeededPercentage = versionAndCounts.reduce(
(acc, item) => acc + item.targetPercentage,
0
);
// Now we have the total percentage after we add everything up, vs the total target percentage we have. Get the difference, then multiply that by the total active agents to get the delta we need to add or remove from the total count.
const totalDeltaPercentage = totalActualPercentage - totalNeededPercentage;
// If we are over, we need to remove some from the count, and if we are under, we need to add some to the count. If we're spot on, all good.
if (totalDeltaPercentage !== 0) {
// get the actual count of agents we are off by using the percentage * the total active agents
let deltaCount = Math.round((totalDeltaPercentage / 100) * totalActiveAgents);
// Now we need to add or remove from the versionAndCounts array
let index = 0;
// So long as we have more to add or remove, do so
while (deltaCount !== 0 && index < versionAndCounts.length) {
const item = versionAndCounts[index];
if (deltaCount > 0) {
// Still have too many, removing one
item.count -= 1;
deltaCount -= 1;
} else if (deltaCount < 0) {
// Still have too few, adding one
if (item.count > 0) {
item.count += 1;
deltaCount += 1;
}
}
index++;
}
}
return versionAndCounts;
}
private async getAgentCount(
esClient: ElasticsearchClient,
@ -250,24 +343,14 @@ export class AutomaticAgentUpgradeTask {
soClient: SavedObjectsClientContract,
agentPolicy: AgentPolicy,
requiredVersion: AgentTargetVersion,
totalActiveAgents: number
versionAndCounts: UpgradeTargetForVersion[]
) {
this.logger.debug(
`[AutomaticAgentUpgradeTask] Agent policy ${agentPolicy.id}: checking candidate agents for upgrade (target version: ${requiredVersion.version}, percentage: ${requiredVersion.percentage})`
);
// Calculate how many agents should meet the version requirement.
let numberOfAgentsForUpgrade = Math.round(
(totalActiveAgents * requiredVersion.percentage) / 100
);
// Subtract total number of agents already or on or updating to target version.
const updatingToKuery = `(upgrade_details.target_version:${requiredVersion.version} AND NOT upgrade_details.state:UPG_FAILED)`;
const totalOnOrUpdatingToTargetVersionAgents = await this.getAgentCount(
esClient,
soClient,
`policy_id:${agentPolicy.id} AND (agent.version:${requiredVersion.version} OR ${updatingToKuery})`
);
numberOfAgentsForUpgrade -= totalOnOrUpdatingToTargetVersionAgents;
let numberOfAgentsForUpgrade =
versionAndCounts.find((item) => item.version === requiredVersion.version)?.count ?? 0;
// Return if target is already met.
if (numberOfAgentsForUpgrade <= 0) {
this.logger.info(
@ -283,6 +366,7 @@ export class AutomaticAgentUpgradeTask {
agentPolicy,
requiredVersion.version
);
numberOfAgentsForUpgrade -= numberOfRetriedAgents;
if (numberOfAgentsForUpgrade <= 0) {
return;
@ -366,6 +450,7 @@ export class AutomaticAgentUpgradeTask {
this.logger.info(
`[AutomaticAgentUpgradeTask] Agent policy ${agentPolicy.id}: retrying upgrade to ${version} for ${agentsReadyForRetry.length} agents`
);
await sendAutomaticUpgradeAgentsActions(soClient, esClient, {
agents: agentsReadyForRetry,
version,

View file

@ -615,6 +615,8 @@ export const GetActionStatusResponseSchema = schema.object({
items: schema.arrayOf(
schema.object({
actionId: schema.string(),
is_automatic: schema.maybe(schema.boolean()),
nbAgentsActionCreated: schema.number({
meta: {
description: 'number of agents included in action from kibana',

View file

@ -72,6 +72,7 @@ export async function cleanFleetAgentPolicies(esClient: Client) {
index: AGENT_POLICY_INDEX,
q: '*',
refresh: true,
ignore_unavailable: true,
});
}