mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
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:
parent
e00aa75631
commit
9e4614647a
14 changed files with 520 additions and 54 deletions
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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();
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -490,6 +490,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
|
|||
setAgentToUpgrade(undefined);
|
||||
refreshAgents();
|
||||
}}
|
||||
isUpdating={Boolean(agentToUpgrade.upgrade_started_at && !agentToUpgrade.upgraded_at)}
|
||||
/>
|
||||
</EuiPortal>
|
||||
)}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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 ? (
|
||||
<>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue