added restart upgrade action (#166154)

## Summary

Ready to review, tests are WIP

Resolves https://github.com/elastic/kibana/issues/135539
 
For agents in `Updating` state, adding a `Restart upgrade` action, which
uses the force flag on the API to bypass the updating check. For single
agent, the action is only added if the agent has started upgrade for
more than 2 hours (see discussion in
[issue](https://github.com/elastic/kibana/issues/135539#issuecomment-1716136658))
For bulk selection, the action appears if all selected agents are in
updating state.

To verify:
- Start local es, kibana, fleet server (can be docker)
- Enroll agents with horde
- update agent docs with the queries below to simulate stuck in updating
for more than 2 hours (modify the query to target specific agents)
- bulk select agents and check that `Restart upgrade` action is visible,
and it triggers another upgrade with force flag only on the agents stuck
in updating
- when calling the bulk_upgrade API, the UI adds the updating condition
to the query / or includes only those agent ids that are stuck in
updating, depending on query or manual selection

```
curl -sk -XPOST --user elastic:changeme -H 'content-type:application/json' \
http://localhost:9200/_security/role/fleet_superuser -d '
   {
      "indices": [
            {
               "names": [".fleet*",".kibana*"],
               "privileges": ["all"],
               "allow_restricted_indices": true
            }
      ]
   }'

curl -sk -XPOST --user elastic:changeme -H 'content-type:application/json' \
http://localhost:9200/_security/user/fleet_superuser -d '
   {
      "password": "password",
      "roles": ["superuser", "fleet_superuser"]
   }'

    curl -sk -XPOST --user fleet_superuser:password -H 'content-type:application/json' \
    -H'x-elastic-product-origin:fleet' \
    http://localhost:9200/.fleet-agents/_update_by_query -d '
    {
       "script": {
         "source": "ctx._source.upgrade_started_at = \"2023-09-13T11:26:23Z\"",
         "lang": "painless"
       },
       "query": {
         "exists": {
           "field": "tags"
         }
       }
     }'
```


Agent details action:

<img width="1440" alt="image"
src="3704e781-337e-4175-b6d2-a99375b6cc24">

Agent list, bulk action:

<img width="1482" alt="image"
src="74f46861-393e-4a86-ab1f-c21e8d325e89">

Agent list, single agent action:

<img width="369" alt="image"
src="2aa087a1-b6e1-4e44-b1db-34592f3df959">

Agent details callout:
<img width="771" alt="image"
src="d1584fe6-0c98-4033-8527-27235812c004">

Select all agents on first page, restart upgrade modal shows only those
that are stuck in upgrading:
<img width="1317" alt="image"
src="fe858815-4393-4007-abe6-e26e4a884bd3">

Select all agents on all pages, restart upgrade modal shows only those
that are stuck upgrading, with an extra api call to get the count:
<img width="2443" alt="image"
src="481b6ab5-fc87-4586-aa9b-432439234ac6">


### 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/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Julia Bardi 2023-09-18 13:37:15 +02:00 committed by GitHub
parent e00aa75631
commit 9e4614647a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 520 additions and 54 deletions

View file

@ -56,3 +56,15 @@ export function buildKueryForUpdatingAgents(): string {
export function buildKueryForInactiveAgents() {
return 'status:inactive';
}
export const AGENT_UPDATING_TIMEOUT_HOURS = 2;
export function isStuckInUpdating(agent: Agent): boolean {
return (
agent.status === 'updating' &&
!!agent.upgrade_started_at &&
!agent.upgraded_at &&
Date.now() - Date.parse(agent.upgrade_started_at) >
AGENT_UPDATING_TIMEOUT_HOURS * 60 * 60 * 1000
);
}

View file

@ -101,6 +101,7 @@ export interface PostAgentUpgradeRequest {
body: {
source_uri?: string;
version: string;
force?: boolean;
};
}
@ -111,6 +112,7 @@ export interface PostBulkAgentUpgradeRequest {
version: string;
rollout_duration_seconds?: number;
start_time?: string;
force?: boolean;
};
}

View file

@ -163,3 +163,44 @@ describe('AgentDetailsActionMenu', () => {
});
});
});
describe('Restart upgrade action', () => {
function renderAndGetRestartUpgradeButton({
agent,
agentPolicy,
}: {
agent: Agent;
agentPolicy?: AgentPolicy;
}) {
const { utils } = renderActions({
agent,
agentPolicy,
});
return utils.queryByTestId('restartUpgradeBtn');
}
it('should render an active button', async () => {
const res = renderAndGetRestartUpgradeButton({
agent: {
status: 'updating',
upgrade_started_at: '2022-11-21T12:27:24Z',
} as any,
agentPolicy: {} as AgentPolicy,
});
expect(res).not.toBe(null);
expect(res).toBeEnabled();
});
it('should not render action if agent is not stuck in updating', async () => {
const res = renderAndGetRestartUpgradeButton({
agent: {
status: 'updating',
upgrade_started_at: new Date().toISOString(),
} as any,
agentPolicy: {} as AgentPolicy,
});
expect(res).toBe(null);
});
});

View file

@ -10,6 +10,7 @@ import { EuiPortal, EuiContextMenuItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { isAgentRequestDiagnosticsSupported } from '../../../../../../../common/services';
import { isStuckInUpdating } from '../../../../../../../common/services/agent_status';
import type { Agent, AgentPolicy } from '../../../../types';
import { useAuthz, useKibanaVersion } from '../../../../hooks';
@ -41,6 +42,7 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{
const [isRequestDiagnosticsModalOpen, setIsRequestDiagnosticsModalOpen] = useState(false);
const [isAgentDetailsJsonFlyoutOpen, setIsAgentDetailsJsonFlyoutOpen] = useState<boolean>(false);
const isUnenrolling = agent.status === 'unenrolling';
const isAgentUpdating = isStuckInUpdating(agent);
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const onContextMenuChange = useCallback(
@ -114,6 +116,24 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{
);
}
if (isAgentUpdating) {
menuItems.push(
<EuiContextMenuItem
icon="refresh"
onClick={() => {
setIsUpgradeModalOpen(true);
}}
key="restartUpgradeAgent"
data-test-subj="restartUpgradeBtn"
>
<FormattedMessage
id="xpack.fleet.agentList.restartUpgradeOneButton"
defaultMessage="Restart upgrade"
/>
</EuiContextMenuItem>
);
}
menuItems.push(
<EuiContextMenuItem
icon="inspect"
@ -180,6 +200,7 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{
setIsUpgradeModalOpen(false);
refreshAgent();
}}
isUpdating={isAgentUpdating}
/>
</EuiPortal>
)}

View file

@ -129,7 +129,7 @@ export const AgentDetailsOverviewSection: React.FunctionComponent<{
title: i18n.translate('xpack.fleet.agentDetails.statusLabel', {
defaultMessage: 'Status',
}),
description: <AgentHealth agent={agent} showOfflinePreviousStatus={true} />,
description: <AgentHealth agent={agent} fromDetails={true} />,
},
{
title: i18n.translate('xpack.fleet.agentDetails.lastActivityLabel', {

View file

@ -80,6 +80,7 @@ describe('AgentBulkActions', () => {
expect(results.getByText('Upgrade 2 agents').closest('button')!).toBeDisabled();
expect(results.getByText('Schedule upgrade for 2 agents').closest('button')!).toBeDisabled();
expect(results.queryByText('Request diagnostics for 2 agents')).toBeNull();
expect(results.getByText('Restart upgrade 2 agents').closest('button')!).toBeDisabled();
});
it('should show available actions for 2 selected agents if they are active', async () => {
@ -112,6 +113,7 @@ describe('AgentBulkActions', () => {
expect(results.getByText('Unenroll 2 agents').closest('button')!).toBeEnabled();
expect(results.getByText('Upgrade 2 agents').closest('button')!).toBeEnabled();
expect(results.getByText('Schedule upgrade for 2 agents').closest('button')!).toBeDisabled();
expect(results.getByText('Restart upgrade 2 agents').closest('button')!).toBeEnabled();
});
it('should add actions if mockedExperimentalFeaturesService is enabled', async () => {
@ -202,6 +204,7 @@ describe('AgentBulkActions', () => {
expect(
results.getByText('Request diagnostics for 10 agents').closest('button')!
).toBeEnabled();
expect(results.getByText('Restart upgrade 10 agents').closest('button')!).toBeEnabled();
});
it('should show correct actions for the active agents and exclude the managed agents from the count', async () => {
@ -255,6 +258,7 @@ describe('AgentBulkActions', () => {
expect(
results.getByText('Request diagnostics for 8 agents').closest('button')!
).toBeEnabled();
expect(results.getByText('Restart upgrade 8 agents').closest('button')!).toBeEnabled();
});
it('should show correct actions when no managed policies exist', async () => {
@ -292,6 +296,7 @@ describe('AgentBulkActions', () => {
expect(
results.getByText('Request diagnostics for 10 agents').closest('button')!
).toBeEnabled();
expect(results.getByText('Restart upgrade 10 agents').closest('button')!).toBeEnabled();
});
it('should generate a correct kuery to select agents', async () => {

View file

@ -73,7 +73,11 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
// Actions states
const [isReassignFlyoutOpen, setIsReassignFlyoutOpen] = useState<boolean>(false);
const [isUnenrollModalOpen, setIsUnenrollModalOpen] = useState<boolean>(false);
const [updateModalState, setUpgradeModalState] = useState({ isOpen: false, isScheduled: false });
const [updateModalState, setUpgradeModalState] = useState({
isOpen: false,
isScheduled: false,
isUpdating: false,
});
const [isTagAddVisible, setIsTagAddVisible] = useState<boolean>(false);
const [isRequestDiagnosticsModalOpen, setIsRequestDiagnosticsModalOpen] =
useState<boolean>(false);
@ -219,7 +223,7 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
disabled: !atLeastOneActiveAgentSelected,
onClick: () => {
closeMenu();
setUpgradeModalState({ isOpen: true, isScheduled: false });
setUpgradeModalState({ isOpen: true, isScheduled: false, isUpdating: false });
},
},
{
@ -237,11 +241,30 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
disabled: !atLeastOneActiveAgentSelected || !isLicenceAllowingScheduleUpgrade,
onClick: () => {
closeMenu();
setUpgradeModalState({ isOpen: true, isScheduled: true });
setUpgradeModalState({ isOpen: true, isScheduled: true, isUpdating: false });
},
},
];
menuItems.push({
name: (
<FormattedMessage
id="xpack.fleet.agentBulkActions.restartUpgradeAgents"
data-test-subj="agentBulkActionsRestartUpgrade"
defaultMessage="Restart upgrade {agentCount, plural, one {# agent} other {# agents}}"
values={{
agentCount,
}}
/>
),
icon: <EuiIcon type="refresh" size="m" />,
disabled: !atLeastOneActiveAgentSelected,
onClick: () => {
closeMenu();
setUpgradeModalState({ isOpen: true, isScheduled: false, isUpdating: true });
},
});
if (diagnosticFileUploadEnabled) {
menuItems.push({
name: (
@ -306,8 +329,9 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
agents={agents}
agentCount={agentCount}
isScheduled={updateModalState.isScheduled}
isUpdating={updateModalState.isUpdating}
onClose={() => {
setUpgradeModalState({ isOpen: false, isScheduled: false });
setUpgradeModalState({ isOpen: false, isScheduled: false, isUpdating: false });
refreshAgents();
}}
/>

View file

@ -132,4 +132,51 @@ describe('TableRowActions', () => {
expect(res).not.toBeEnabled();
});
});
describe('Restart upgrade action', () => {
function renderAndGetRestartUpgradeButton({
agent,
agentPolicy,
}: {
agent: Agent;
agentPolicy?: AgentPolicy;
}) {
const { utils } = renderTableRowActions({
agent,
agentPolicy,
});
return utils.queryByTestId('restartUpgradeBtn');
}
it('should render an active button', async () => {
const res = renderAndGetRestartUpgradeButton({
agent: {
active: true,
status: 'updating',
upgrade_started_at: '2022-11-21T12:27:24Z',
} as any,
agentPolicy: {
is_managed: false,
} as AgentPolicy,
});
expect(res).not.toBe(null);
expect(res).toBeEnabled();
});
it('should not render action if agent is not stuck in updating', async () => {
const res = renderAndGetRestartUpgradeButton({
agent: {
active: true,
status: 'updating',
upgrade_started_at: new Date().toISOString(),
} as any,
agentPolicy: {
is_managed: false,
} as AgentPolicy,
});
expect(res).toBe(null);
});
});
});

View file

@ -11,6 +11,8 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { isAgentRequestDiagnosticsSupported } from '../../../../../../../common/services';
import { isStuckInUpdating } from '../../../../../../../common/services/agent_status';
import type { Agent, AgentPolicy } from '../../../../types';
import { useAuthz, useLink, useKibanaVersion } from '../../../../hooks';
import { ContextMenuActions } from '../../../../components';
@ -117,6 +119,24 @@ export const TableRowActions: React.FunctionComponent<{
</EuiContextMenuItem>
);
if (isStuckInUpdating(agent)) {
menuItems.push(
<EuiContextMenuItem
key="agentRestartUpgradeBtn"
icon="refresh"
onClick={() => {
onUpgradeClick();
}}
data-test-subj="restartUpgradeBtn"
>
<FormattedMessage
id="xpack.fleet.agentList.restartUpgradeOneButton"
defaultMessage="Restart upgrade"
/>
</EuiContextMenuItem>
);
}
if (agentTamperProtectionEnabled && agent.policy_id) {
menuItems.push(
<EuiContextMenuItem

View file

@ -490,6 +490,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
setAgentToUpgrade(undefined);
refreshAgents();
}}
isUpdating={Boolean(agentToUpgrade.upgrade_started_at && !agentToUpgrade.upgraded_at)}
/>
</EuiPortal>
)}

View file

@ -0,0 +1,76 @@
/*
* 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, fireEvent } from '@testing-library/react';
import { createFleetTestRendererMock } from '../../../../../mock';
import type { Agent } from '../../../types';
import { AgentHealth } from './agent_health';
jest.mock('./agent_upgrade_modal', () => {
return {
AgentUpgradeAgentModal: () => <>Upgrade Modal</>,
};
});
function renderAgentHealth(agent: Agent, fromDetails?: boolean) {
const renderer = createFleetTestRendererMock();
const utils = renderer.render(<AgentHealth agent={agent} fromDetails={fromDetails} />);
return { utils };
}
describe('AgentHealth', () => {
it('should render agent health with callout when agent stuck updating', () => {
const { utils } = renderAgentHealth(
{
active: true,
status: 'updating',
upgrade_started_at: '2022-11-21T12:27:24Z',
} as any,
true
);
act(() => {
fireEvent.click(utils.getByTestId('restartUpgradeBtn'));
});
utils.findByText('Upgrade Modal');
});
it('should not render agent health with callout when agent not stuck updating', () => {
const { utils } = renderAgentHealth(
{
active: true,
status: 'updating',
upgrade_started_at: new Date().toISOString(),
} as any,
true
);
expect(utils.queryByTestId('restartUpgradeBtn')).not.toBeInTheDocument();
});
it('should not render agent health with callout when not from details', () => {
const { utils } = renderAgentHealth(
{
active: true,
status: 'updating',
upgrade_started_at: '2022-11-21T12:27:24Z',
} as any,
false
);
expect(utils.queryByTestId('restartUpgradeBtn')).not.toBeInTheDocument();
expect(utils.container.querySelector('[data-euiicon-type="warning"]')).not.toBeNull();
});
});

View file

@ -5,19 +5,35 @@
* 2.0.
*/
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import styled from 'styled-components';
import { FormattedMessage, FormattedRelative } from '@kbn/i18n-react';
import { EuiBadge, EuiToolTip } from '@elastic/eui';
import {
EuiBadge,
EuiButton,
EuiCallOut,
EuiIcon,
EuiPortal,
EuiSpacer,
EuiToolTip,
} from '@elastic/eui';
import { euiLightVars as euiVars } from '@kbn/ui-theme';
import { getPreviousAgentStatusForOfflineAgents } from '../../../../../../common/services/agent_status';
import {
getPreviousAgentStatusForOfflineAgents,
isStuckInUpdating,
} from '../../../../../../common/services/agent_status';
import type { Agent } from '../../../types';
import { useAgentRefresh } from '../agent_details_page/hooks';
import { AgentUpgradeAgentModal } from './agent_upgrade_modal';
interface Props {
agent: Agent;
showOfflinePreviousStatus?: boolean;
fromDetails?: boolean;
}
const Status = {
@ -79,10 +95,11 @@ function getStatusComponent(status: Agent['status']): React.ReactElement {
}
}
export const AgentHealth: React.FunctionComponent<Props> = ({
agent,
showOfflinePreviousStatus,
}) => {
const WrappedEuiCallOut = styled(EuiCallOut)`
white-space: wrap !important;
`;
export const AgentHealth: React.FunctionComponent<Props> = ({ agent, fromDetails }) => {
const { last_checkin: lastCheckIn, last_checkin_message: lastCheckInMessage } = agent;
const msLastCheckIn = new Date(lastCheckIn || 0).getTime();
const lastCheckInMessageText = lastCheckInMessage ? (
@ -112,27 +129,94 @@ export const AgentHealth: React.FunctionComponent<Props> = ({
);
const previousToOfflineStatus = useMemo(() => {
if (!showOfflinePreviousStatus || agent.status !== 'offline') {
if (!fromDetails || agent.status !== 'offline') {
return;
}
return getPreviousAgentStatusForOfflineAgents(agent);
}, [showOfflinePreviousStatus, agent]);
}, [fromDetails, agent]);
const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false);
const refreshAgent = useAgentRefresh();
return (
<EuiToolTip
position="top"
content={
<>
<EuiToolTip
position="top"
content={
<>
<p>{lastCheckinText}</p>
<p>{lastCheckInMessageText}</p>
{isStuckInUpdating(agent) ? (
<p>
<FormattedMessage
id="xpack.fleet.agentHealth.restartUpgradeTooltipText"
defaultMessage="Agent may be stuck updating. Consider restarting the upgrade."
/>
</p>
) : null}
</>
}
>
<>
<p>{lastCheckinText}</p>
<p>{lastCheckInMessageText}</p>
{getStatusComponent(agent.status)}
{previousToOfflineStatus ? getStatusComponent(previousToOfflineStatus) : null}
{isStuckInUpdating(agent) && !fromDetails ? (
<>
&nbsp;
<EuiIcon type="warning" />
</>
) : null}
</>
}
>
<>
{getStatusComponent(agent.status)}
{previousToOfflineStatus ? getStatusComponent(previousToOfflineStatus) : null}
</>
</EuiToolTip>
</EuiToolTip>
{fromDetails && isStuckInUpdating(agent) ? (
<>
<EuiSpacer size="m" />
<WrappedEuiCallOut
iconType="warning"
size="m"
color="warning"
title={
<FormattedMessage
id="xpack.fleet.agentHealth.stuckUpdatingTitle"
defaultMessage="Agent may be stuck updating."
/>
}
>
<p>
<FormattedMessage
id="xpack.fleet.agentHealth.stuckUpdatingText"
defaultMessage="Agent has been updating for a while, and may be stuck. Consider restarting the upgrade."
/>
</p>
<EuiButton
color="warning"
onClick={() => {
setIsUpgradeModalOpen(true);
}}
data-test-subj="restartUpgradeBtn"
>
<FormattedMessage
id="xpack.fleet.agentHealth.restartUpgradeBtn"
defaultMessage="Restart upgrade"
/>
</EuiButton>
</WrappedEuiCallOut>
</>
) : null}
{isUpgradeModalOpen && (
<EuiPortal>
<AgentUpgradeAgentModal
agents={[agent]}
agentCount={1}
onClose={() => {
setIsUpgradeModalOpen(false);
refreshAgent();
}}
isUpdating={true}
/>
</EuiPortal>
)}
</>
);
};

View file

@ -7,20 +7,15 @@
import React from 'react';
import { waitFor } from '@testing-library/react';
import { act, fireEvent, waitFor } from '@testing-library/react';
import { createFleetTestRendererMock } from '../../../../../../mock';
import { sendPostBulkAgentUpgrade } from '../../../../hooks';
import { AgentUpgradeAgentModal } from '.';
import type { AgentUpgradeAgentModalProps } from '.';
jest.mock('@elastic/eui', () => {
return {
...jest.requireActual('@elastic/eui'),
EuiConfirmModal: ({ children }: any) => <>{children}</>,
};
});
jest.mock('../../../../hooks', () => {
return {
...jest.requireActual('../../../../hooks'),
@ -29,9 +24,15 @@ jest.mock('../../../../hooks', () => {
items: ['8.7.0'],
},
}),
sendGetAgentStatus: jest.fn().mockResolvedValue({
data: { results: { updating: 2 } },
}),
sendPostBulkAgentUpgrade: jest.fn(),
};
});
const mockSendPostBulkAgentUpgrade = sendPostBulkAgentUpgrade as jest.Mock;
function renderAgentUpgradeAgentModal(props: Partial<AgentUpgradeAgentModalProps>) {
const renderer = createFleetTestRendererMock();
@ -41,6 +42,7 @@ function renderAgentUpgradeAgentModal(props: Partial<AgentUpgradeAgentModalProps
return { utils };
}
describe('AgentUpgradeAgentModal', () => {
it('should set the default to Immediately if there is less than 10 agents using kuery', async () => {
const { utils } = renderAgentUpgradeAgentModal({
@ -48,10 +50,7 @@ describe('AgentUpgradeAgentModal', () => {
agentCount: 3,
});
const el = utils.container.querySelector(
'[data-test-subj="agentUpgradeModal.MaintenanceCombobox"]'
);
expect(el).not.toBeNull();
const el = utils.getByTestId('agentUpgradeModal.MaintenanceCombobox');
expect(el?.textContent).toBe('Immediately');
});
@ -61,10 +60,7 @@ describe('AgentUpgradeAgentModal', () => {
agentCount: 3,
});
const el = utils.container.querySelector(
'[data-test-subj="agentUpgradeModal.MaintenanceCombobox"]'
);
expect(el).not.toBeNull();
const el = utils.getByTestId('agentUpgradeModal.MaintenanceCombobox');
expect(el?.textContent).toBe('Immediately');
});
@ -74,11 +70,7 @@ describe('AgentUpgradeAgentModal', () => {
agentCount: 13,
});
const el = utils.container.querySelector(
'[data-test-subj="agentUpgradeModal.MaintenanceCombobox"]'
);
expect(el).not.toBeNull();
const el = utils.getByTestId('agentUpgradeModal.MaintenanceCombobox');
expect(el?.textContent).toBe('1 hour');
});
@ -93,4 +85,73 @@ describe('AgentUpgradeAgentModal', () => {
expect(el.classList.contains('euiComboBox-isDisabled')).toBe(false);
});
});
it('should restart uprade on updating agents if some agents in updating', async () => {
const { utils } = renderAgentUpgradeAgentModal({
agents: [
{ status: 'updating', upgrade_started_at: '2022-11-21T12:27:24Z', id: 'agent1' },
{ id: 'agent2' },
] as any,
agentCount: 2,
isUpdating: true,
});
const el = utils.getByTestId('confirmModalTitleText');
expect(el.textContent).toEqual('Restart upgrade on 1 out of 2 agents stuck in updating');
const btn = utils.getByTestId('confirmModalConfirmButton');
await waitFor(() => {
expect(btn).toBeEnabled();
});
act(() => {
fireEvent.click(btn);
});
expect(mockSendPostBulkAgentUpgrade.mock.calls.at(-1)[0]).toEqual(
expect.objectContaining({ agents: ['agent1'], force: true })
);
});
it('should restart upgrade on updating agents if kuery', async () => {
const { utils } = renderAgentUpgradeAgentModal({
agents: '*',
agentCount: 3,
isUpdating: true,
});
const el = await utils.findByTestId('confirmModalTitleText');
expect(el.textContent).toEqual('Restart upgrade on 2 out of 3 agents stuck in updating');
const btn = utils.getByTestId('confirmModalConfirmButton');
await waitFor(() => {
expect(btn).toBeEnabled();
});
act(() => {
fireEvent.click(btn);
});
expect(mockSendPostBulkAgentUpgrade.mock.calls.at(-1)[0]).toEqual(
expect.objectContaining({
agents:
'(*) AND status:updating AND upgrade_started_at:* AND NOT upgraded_at:* AND upgrade_started_at < now-2h',
force: true,
})
);
});
it('should disable submit button if no agents stuck updating', () => {
const { utils } = renderAgentUpgradeAgentModal({
agents: [
{ status: 'offline', upgrade_started_at: '2022-11-21T12:27:24Z', id: 'agent1' },
{ id: 'agent2' },
] as any,
agentCount: 2,
isUpdating: true,
});
const el = utils.getByTestId('confirmModalConfirmButton');
expect(el).toBeDisabled();
});
});

View file

@ -28,6 +28,11 @@ import semverGt from 'semver/functions/gt';
import semverLt from 'semver/functions/lt';
import { getMinVersion } from '../../../../../../../common/services/get_min_max_version';
import {
AGENT_UPDATING_TIMEOUT_HOURS,
isStuckInUpdating,
} from '../../../../../../../common/services/agent_status';
import type { Agent } from '../../../../types';
import {
sendPostAgentUpgrade,
@ -35,6 +40,7 @@ import {
useStartServices,
useKibanaVersion,
useConfig,
sendGetAgentStatus,
} from '../../../../hooks';
import { sendGetAgentsAvailableVersions } from '../../../../hooks';
@ -51,6 +57,7 @@ export interface AgentUpgradeAgentModalProps {
agents: Agent[] | string;
agentCount: number;
isScheduled?: boolean;
isUpdating?: boolean;
}
const getVersion = (version: Array<EuiComboBoxOptionOption<string>>) => version[0]?.value as string;
@ -68,6 +75,7 @@ export const AgentUpgradeAgentModal: React.FunctionComponent<AgentUpgradeAgentMo
agents,
agentCount,
isScheduled = false,
isUpdating = false,
}) => {
const { notifications } = useStartServices();
const kibanaVersion = useKibanaVersion() || '';
@ -80,6 +88,47 @@ export const AgentUpgradeAgentModal: React.FunctionComponent<AgentUpgradeAgentMo
const isSmallBatch = agentCount <= 10;
const isAllAgents = agents === '';
const [updatingAgents, setUpdatingAgents] = useState<number>(0);
const [updatingQuery, setUpdatingQuery] = useState<Agent[] | string>('');
const QUERY_STUCK_UPDATING = `status:updating AND upgrade_started_at:* AND NOT upgraded_at:* AND upgrade_started_at < now-${AGENT_UPDATING_TIMEOUT_HOURS}h`;
useEffect(() => {
const getStuckUpdatingAgentCount = async (agentsOrQuery: Agent[] | string) => {
let newQuery;
// find updating agents from array
if (Array.isArray(agentsOrQuery) && agentsOrQuery.length > 0) {
if (agentsOrQuery.length === 0) {
return;
}
const newAgents = agentsOrQuery.filter((agent) => isStuckInUpdating(agent));
const updatingCount = newAgents.length;
setUpdatingAgents(updatingCount);
setUpdatingQuery(newAgents);
return;
} else if (typeof agentsOrQuery === 'string' && agentsOrQuery !== '') {
newQuery = [`(${agentsOrQuery})`, QUERY_STUCK_UPDATING].join(' AND ');
} else {
newQuery = QUERY_STUCK_UPDATING;
}
setUpdatingQuery(newQuery);
// if selection is a query, do an api call to get updating agents
try {
const res = await sendGetAgentStatus({
kuery: newQuery,
});
setUpdatingAgents(res?.data?.results?.updating ?? 0);
} catch (err) {
return;
}
};
if (!isUpdating) return;
getStuckUpdatingAgentCount(agents);
}, [isUpdating, setUpdatingQuery, QUERY_STUCK_UPDATING, agents]);
useEffect(() => {
const getVersions = async () => {
try {
@ -166,14 +215,18 @@ export const AgentUpgradeAgentModal: React.FunctionComponent<AgentUpgradeAgentMo
try {
setIsSubmitting(true);
const getQuery = (agentsOrQuery: Agent[] | string) =>
Array.isArray(agentsOrQuery) ? agentsOrQuery.map((agent) => agent.id) : agentsOrQuery;
const { error } =
isSingleAgent && !isScheduled
? await sendPostAgentUpgrade((agents[0] as Agent).id, {
version,
force: isUpdating,
})
: await sendPostBulkAgentUpgrade({
version,
agents: Array.isArray(agents) ? agents.map((agent) => agent.id) : agents,
agents: getQuery(isUpdating ? updatingQuery : agents),
force: isUpdating,
...rolloutOptions,
});
if (error) {
@ -219,16 +272,29 @@ export const AgentUpgradeAgentModal: React.FunctionComponent<AgentUpgradeAgentMo
title={
<>
{isSingleAgent ? (
<FormattedMessage
id="xpack.fleet.upgradeAgents.upgradeSingleTitle"
defaultMessage="Upgrade agent"
/>
isUpdating ? (
<FormattedMessage
id="xpack.fleet.upgradeAgents.restartUpgradeSingleTitle"
defaultMessage="Restart upgrade"
/>
) : (
<FormattedMessage
id="xpack.fleet.upgradeAgents.upgradeSingleTitle"
defaultMessage="Upgrade agent"
/>
)
) : isScheduled ? (
<FormattedMessage
id="xpack.fleet.upgradeAgents.scheduleUpgradeMultipleTitle"
defaultMessage="Schedule upgrade for {count, plural, one {agent} other {{count} agents} =true {all selected agents}}"
values={{ count: isAllAgents || agentCount }}
/>
) : isUpdating ? (
<FormattedMessage
id="xpack.fleet.upgradeAgents.restartUpgradeMultipleTitle"
defaultMessage="Restart upgrade on {updating} out of {count, plural, one {agent} other {{count} agents} =true {all agents}} stuck in updating"
values={{ count: isAllAgents || agentCount, updating: updatingAgents }}
/>
) : (
<FormattedMessage
id="xpack.fleet.upgradeAgents.upgradeMultipleTitle"
@ -246,7 +312,7 @@ export const AgentUpgradeAgentModal: React.FunctionComponent<AgentUpgradeAgentMo
defaultMessage="Cancel"
/>
}
confirmButtonDisabled={isSubmitting || noVersions}
confirmButtonDisabled={isSubmitting || noVersions || (isUpdating && updatingAgents === 0)}
confirmButtonText={
isSingleAgent ? (
<FormattedMessage
@ -258,6 +324,12 @@ export const AgentUpgradeAgentModal: React.FunctionComponent<AgentUpgradeAgentMo
id="xpack.fleet.upgradeAgents.confirmScheduleMultipleButtonLabel"
defaultMessage="Schedule"
/>
) : isUpdating ? (
<FormattedMessage
id="xpack.fleet.upgradeAgents.restartConfirmMultipleButtonLabel"
defaultMessage="Restart upgrade {count, plural, one {agent} other {{count} agents} =true {all selected agents}}"
values={{ count: updatingAgents }}
/>
) : (
<FormattedMessage
id="xpack.fleet.upgradeAgents.confirmMultipleButtonLabel"