diff --git a/x-pack/plugins/fleet/common/services/routes.ts b/x-pack/plugins/fleet/common/services/routes.ts index d4e8375bbaa5d..a8a6c34f06f3c 100644 --- a/x-pack/plugins/fleet/common/services/routes.ts +++ b/x-pack/plugins/fleet/common/services/routes.ts @@ -175,6 +175,9 @@ export const agentRouteService = { getUpgradePath: (agentId: string) => AGENT_API_ROUTES.UPGRADE_PATTERN.replace('{agentId}', agentId), getBulkUpgradePath: () => AGENT_API_ROUTES.BULK_UPGRADE_PATTERN, + getCurrentUpgradesPath: () => AGENT_API_ROUTES.CURRENT_UPGRADES_PATTERN, + getCancelActionPath: (actionId: string) => + AGENT_API_ROUTES.CANCEL_ACTIONS_PATTERN.replace('{actionId}', actionId), getListPath: () => AGENT_API_ROUTES.LIST_PATTERN, getStatusPath: () => AGENT_API_ROUTES.STATUS_PATTERN, getIncomingDataPath: () => AGENT_API_ROUTES.DATA_PATTERN, diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index b3847ac8c6892..a26f63eba755b 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -98,6 +98,7 @@ export interface CurrentUpgrade { complete: boolean; nbAgents: number; nbAgentsAck: number; + version: string; } // Generated from FleetServer schema.json diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/current_bulk_upgrade_callout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/current_bulk_upgrade_callout.tsx new file mode 100644 index 0000000000000..a77c26f8fef2f --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/current_bulk_upgrade_callout.tsx @@ -0,0 +1,89 @@ +/* + * 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, { useCallback, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiCallOut, + EuiLink, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiLoadingSpinner, +} from '@elastic/eui'; + +import { useStartServices } from '../../../../hooks'; +import type { CurrentUpgrade } from '../../../../types'; + +export interface CurrentBulkUpgradeCalloutProps { + currentUpgrade: CurrentUpgrade; + abortUpgrade: (currentUpgrade: CurrentUpgrade) => Promise<void>; +} + +export const CurrentBulkUpgradeCallout: React.FunctionComponent<CurrentBulkUpgradeCalloutProps> = ({ + currentUpgrade, + abortUpgrade, +}) => { + const { docLinks } = useStartServices(); + const [isAborting, setIsAborting] = useState(false); + const onClickAbortUpgrade = useCallback(async () => { + try { + setIsAborting(true); + await abortUpgrade(currentUpgrade); + } finally { + setIsAborting(false); + } + }, [currentUpgrade, abortUpgrade]); + + return ( + <EuiCallOut color="primary"> + <EuiFlexGroup + className="euiCallOutHeader__title" + justifyContent="spaceBetween" + alignItems="center" + gutterSize="none" + > + <EuiFlexItem grow={false}> + <div> + <EuiLoadingSpinner /> + + <FormattedMessage + id="xpack.fleet.currentUpgrade.calloutTitle" + defaultMessage="Upgrading {nbAgents} agents to version {version}" + values={{ + nbAgents: currentUpgrade.nbAgents - currentUpgrade.nbAgentsAck, + version: currentUpgrade.version, + }} + /> + </div> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButton size="s" onClick={onClickAbortUpgrade} isLoading={isAborting}> + <FormattedMessage + id="xpack.fleet.currentUpgrade.abortUpgradeButtom" + defaultMessage="Abort upgrade" + /> + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + <FormattedMessage + id="xpack.fleet.currentUpgrade.calloutDescription" + defaultMessage="For more information see the {guideLink}." + values={{ + guideLink: ( + <EuiLink href={docLinks.links.fleet.fleetServerAddFleetServer} target="_blank" external> + <FormattedMessage + id="xpack.fleet.currentUpgrade.guideLink" + defaultMessage="Fleet and Elastic Agent Guide" + /> + </EuiLink> + ), + }} + /> + </EuiCallOut> + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/index.tsx new file mode 100644 index 0000000000000..36028c0d2c9b5 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/index.tsx @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export { CurrentBulkUpgradeCallout } from './current_bulk_upgrade_callout'; +export type { CurrentBulkUpgradeCalloutProps } from './current_bulk_upgrade_callout'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/index.tsx new file mode 100644 index 0000000000000..4ab06bfcc8a91 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/index.tsx @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { useCurrentUpgrades } from './use_current_upgrades'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx new file mode 100644 index 0000000000000..02463025c86db --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx @@ -0,0 +1,108 @@ +/* + * 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 { useCallback, useEffect, useRef, useState } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { sendGetCurrentUpgrades, sendPostCancelAction, useStartServices } from '../../../../hooks'; + +import type { CurrentUpgrade } from '../../../../types'; + +const POLL_INTERVAL = 30 * 1000; + +export function useCurrentUpgrades() { + const [currentUpgrades, setCurrentUpgrades] = useState<CurrentUpgrade[]>([]); + const currentTimeoutRef = useRef<NodeJS.Timeout>(); + const isCancelledRef = useRef<boolean>(false); + const { notifications, overlays } = useStartServices(); + + const refreshUpgrades = useCallback(async () => { + try { + const res = await sendGetCurrentUpgrades(); + if (isCancelledRef.current) { + return; + } + if (res.error) { + throw res.error; + } + + if (!res.data) { + throw new Error('No data'); + } + + setCurrentUpgrades(res.data.items); + } catch (err) { + notifications.toasts.addError(err, { + title: i18n.translate('xpack.fleet.currentUpgrade.fetchRequestError', { + defaultMessage: 'An error happened while fetching current upgrades', + }), + }); + } + }, [notifications.toasts]); + + const abortUpgrade = useCallback( + async (currentUpgrade: CurrentUpgrade) => { + try { + const confirmRes = await overlays.openConfirm( + i18n.translate('xpack.fleet.currentUpgrade.confirmDescription', { + defaultMessage: 'This action will abort upgrade of {nbAgents} agents', + values: { + nbAgents: currentUpgrade.nbAgents - currentUpgrade.nbAgentsAck, + }, + }), + { + title: i18n.translate('xpack.fleet.currentUpgrade.confirmTitle', { + defaultMessage: 'Abort upgrade?', + }), + } + ); + + if (!confirmRes) { + return; + } + await sendPostCancelAction(currentUpgrade.actionId); + await refreshUpgrades(); + } catch (err) { + notifications.toasts.addError(err, { + title: i18n.translate('xpack.fleet.currentUpgrade.abortRequestError', { + defaultMessage: 'An error happened while aborting upgrade', + }), + }); + } + }, + [refreshUpgrades, notifications.toasts, overlays] + ); + + // Poll for upgrades + useEffect(() => { + isCancelledRef.current = false; + + async function pollData() { + await refreshUpgrades(); + if (isCancelledRef.current) { + return; + } + currentTimeoutRef.current = setTimeout(() => pollData(), POLL_INTERVAL); + } + + pollData(); + + return () => { + isCancelledRef.current = true; + + if (currentTimeoutRef.current) { + clearTimeout(currentTimeoutRef.current); + } + }; + }, [refreshUpgrades]); + + return { + currentUpgrades, + refreshUpgrades, + abortUpgrade, + }; +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index be38f7688c735..f12a99c6e37f9 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -46,12 +46,14 @@ import { } from '../components'; import { useFleetServerUnhealthy } from '../hooks/use_fleet_server_unhealthy'; +import { CurrentBulkUpgradeCallout } from './components'; import { AgentTableHeader } from './components/table_header'; import type { SelectionMode } from './components/types'; import { SearchAndFilterBar } from './components/search_and_filter_bar'; import { Tags } from './components/tags'; import { TableRowActions } from './components/table_row_actions'; import { EmptyPrompt } from './components/empty_prompt'; +import { useCurrentUpgrades } from './hooks'; const REFRESH_INTERVAL_MS = 30000; @@ -335,6 +337,9 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { flyoutContext.openFleetServerFlyout(); }, [flyoutContext]); + // Current upgrades + const { abortUpgrade, currentUpgrades, refreshUpgrades } = useCurrentUpgrades(); + const columns = [ { field: 'local_metadata.host.hostname', @@ -490,7 +495,6 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { /> </EuiPortal> )} - {agentToUpgrade && ( <EuiPortal> <AgentUpgradeAgentModal @@ -499,12 +503,12 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { onClose={() => { setAgentToUpgrade(undefined); fetchData(); + refreshUpgrades(); }} version={kibanaVersion} /> </EuiPortal> )} - {isFleetServerUnhealthy && ( <> {cloud?.deploymentUrl ? ( @@ -515,7 +519,13 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { <EuiSpacer size="l" /> </> )} - + {/* Current upgrades callout */} + {currentUpgrades.map((currentUpgrade) => ( + <React.Fragment key={currentUpgrade.actionId}> + <CurrentBulkUpgradeCallout currentUpgrade={currentUpgrade} abortUpgrade={abortUpgrade} /> + <EuiSpacer size="l" /> + </React.Fragment> + ))} {/* Search and filter bar */} <SearchAndFilterBar agentPolicies={agentPolicies} @@ -539,7 +549,6 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { refreshAgents={() => fetchData()} /> <EuiSpacer size="m" /> - {/* Agent total, bulk actions and status bar */} <AgentTableHeader showInactive={showInactive} @@ -557,7 +566,6 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { }} /> <EuiSpacer size="s" /> - {/* Agent list table */} <EuiBasicTable<Agent> ref={tableRef} diff --git a/x-pack/plugins/fleet/public/hooks/use_request/agents.ts b/x-pack/plugins/fleet/public/hooks/use_request/agents.ts index 9bfba13052c35..94390d2f529d2 100644 --- a/x-pack/plugins/fleet/public/hooks/use_request/agents.ts +++ b/x-pack/plugins/fleet/public/hooks/use_request/agents.ts @@ -29,6 +29,7 @@ import type { PostBulkAgentUpgradeResponse, PostNewAgentActionRequest, PostNewAgentActionResponse, + GetCurrentUpgradesResponse, } from '../../types'; import { useRequest, sendRequest } from './use_request'; @@ -177,3 +178,17 @@ export function sendPostBulkAgentUpgrade( ...options, }); } + +export function sendGetCurrentUpgrades() { + return sendRequest<GetCurrentUpgradesResponse>({ + path: agentRouteService.getCurrentUpgradesPath(), + method: 'get', + }); +} + +export function sendPostCancelAction(actionId: string) { + return sendRequest<GetCurrentUpgradesResponse>({ + path: agentRouteService.getCancelActionPath(actionId), + method: 'post', + }); +} diff --git a/x-pack/plugins/fleet/public/types/index.ts b/x-pack/plugins/fleet/public/types/index.ts index fc29f046aac04..2cd27e81be9d8 100644 --- a/x-pack/plugins/fleet/public/types/index.ts +++ b/x-pack/plugins/fleet/public/types/index.ts @@ -25,6 +25,7 @@ export type { Output, DataStream, Settings, + CurrentUpgrade, GetFleetStatusResponse, GetAgentPoliciesRequest, GetAgentPoliciesResponse, @@ -77,6 +78,7 @@ export type { PostEnrollmentAPIKeyResponse, PostLogstashApiKeyResponse, GetOutputsResponse, + GetCurrentUpgradesResponse, PutOutputRequest, PutOutputResponse, PostOutputRequest, diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index 55c105495fd54..6d0174e064184 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -331,6 +331,7 @@ async function _getUpgradeActions(esClient: ElasticsearchClient, now = new Date( nbAgents: 0, complete: false, nbAgentsAck: 0, + version: hit._source.data?.version as string, }; }