Add bulk migrations UI (#224334)

## Summary

Closes https://github.com/elastic/kibana/issues/217619 
Closes https://github.com/elastic/ingest-dev/issues/5695

Adds 'Migrate X Agents' option to the bulk actions on the agents table

- Reuses the existing migrateAgentFlyout component with some adjustments
for single vs bulk agents
- Added a panel to show if the user selects some protected or
fleet-server agents alerting them that they will not be able to migrate
those agents and they will be omitted.
- Added conditional rendering of the `replace token` switch, as per the
requirements, its only allowed for single agent migrations.
- Also adds feature flag gate to UI (API gate was added in
https://github.com/elastic/kibana/pull/224143)




https://github.com/user-attachments/assets/7f50168a-a388-4274-b8c8-aa0ce38591ed





### 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

# Release Note

- Added the ability to migrate bulk agents to another cluster via the
bulk actions menu of the agent list table (experimental).

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Colleen McGinnis <colleen.j.mcginnis@gmail.com>
This commit is contained in:
Mason Herron 2025-06-23 11:29:35 -06:00 committed by GitHub
parent fddf9f45d9
commit c868136f48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 313 additions and 48 deletions

View file

@ -312,6 +312,7 @@ export const agentRouteService = {
getAgentsByActionsPath: () => AGENT_API_ROUTES.LIST_PATTERN, getAgentsByActionsPath: () => AGENT_API_ROUTES.LIST_PATTERN,
postMigrateSingleAgent: (agentId: string) => postMigrateSingleAgent: (agentId: string) =>
AGENT_API_ROUTES.MIGRATE_PATTERN.replace('{agentId}', agentId), AGENT_API_ROUTES.MIGRATE_PATTERN.replace('{agentId}', agentId),
postBulkMigrateAgents: () => AGENT_API_ROUTES.BULK_MIGRATE_PATTERN,
}; };
export const outputRoutesService = { export const outputRoutesService = {

View file

@ -207,6 +207,30 @@ export interface MigrateSingleAgentRequest {
export interface MigrateSingleAgentResponse { export interface MigrateSingleAgentResponse {
actionId: string; actionId: string;
} }
export interface BulkMigrateAgentsRequest {
body: {
agents: string[];
enrollment_token: string;
uri: string;
settings?: {
ca_sha256?: string;
certificate_authorities?: string;
elastic_agent_cert?: string;
elastic_agent_cert_key?: string;
elastic_agent_cert_key_passphrase?: string;
headers?: Record<string, string>;
insecure?: boolean;
proxy_disabled?: boolean;
proxy_headers?: Record<string, string>;
proxy_url?: string;
staging?: boolean;
tags?: string;
};
};
}
export interface BulkMigrateAgentsResponse {
actionId: string;
}
export interface UpdateAgentRequest { export interface UpdateAgentRequest {
params: { params: {
agentId: string; agentId: string;

View file

@ -49,7 +49,9 @@ const defaultProps = {
describe('AgentBulkActions', () => { describe('AgentBulkActions', () => {
beforeAll(() => { beforeAll(() => {
mockedExperimentalFeaturesService.get.mockReturnValue({} as any); mockedExperimentalFeaturesService.get.mockReturnValue({
enableAgentMigrations: true,
} as any);
jest.mocked(useAuthz).mockReturnValue({ jest.mocked(useAuthz).mockReturnValue({
fleet: { fleet: {
allAgents: true, allAgents: true,
@ -93,6 +95,7 @@ describe('AgentBulkActions', () => {
expect( expect(
results.getByText('Request diagnostics for 2 agents').closest('button')! results.getByText('Request diagnostics for 2 agents').closest('button')!
).toBeEnabled(); ).toBeEnabled();
expect(results.getByText('Migrate 2 agents').closest('button')!).toBeEnabled();
}); });
it('should allow scheduled upgrades if the license allows it', async () => { it('should allow scheduled upgrades if the license allows it', async () => {
@ -210,5 +213,19 @@ describe('AgentBulkActions', () => {
expect.anything() expect.anything()
); );
}); });
it('should not show the migrate button when agent migrations flag is disabled', async () => {
mockedExperimentalFeaturesService.get.mockReturnValue({
enableAgentMigrations: false,
} as any);
const results = render({
...defaultProps,
selectedAgents: [{ id: 'agent1', tags: ['oldTag'] }, { id: 'agent2' }] as Agent[],
});
const bulkActionsButton = results.queryByTestId('agentBulkActionsBulkMigrate');
expect(bulkActionsButton).not.toBeInTheDocument();
});
}); });
}); });

View file

@ -17,6 +17,8 @@ import {
} from '@elastic/eui'; } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react'; import { FormattedMessage } from '@kbn/i18n-react';
import { ExperimentalFeaturesService } from '../../../../services';
import type { Agent, AgentPolicy } from '../../../../types'; import type { Agent, AgentPolicy } from '../../../../types';
import { import {
AgentReassignAgentPolicyModal, AgentReassignAgentPolicyModal,
@ -49,6 +51,7 @@ export interface Props {
agentPolicies: AgentPolicy[]; agentPolicies: AgentPolicy[];
sortField?: string; sortField?: string;
sortOrder?: 'asc' | 'desc'; sortOrder?: 'asc' | 'desc';
onBulkMigrateClicked: (agents: Agent[]) => void;
} }
export const AgentBulkActions: React.FunctionComponent<Props> = ({ export const AgentBulkActions: React.FunctionComponent<Props> = ({
@ -63,11 +66,12 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
agentPolicies, agentPolicies,
sortField, sortField,
sortOrder, sortOrder,
onBulkMigrateClicked,
}) => { }) => {
const licenseService = useLicense(); const licenseService = useLicense();
const authz = useAuthz(); const authz = useAuthz();
const isLicenceAllowingScheduleUpgrade = licenseService.hasAtLeast(LICENSE_FOR_SCHEDULE_UPGRADE); const isLicenceAllowingScheduleUpgrade = licenseService.hasAtLeast(LICENSE_FOR_SCHEDULE_UPGRADE);
const agentMigrationsEnabled = ExperimentalFeaturesService.get().enableAgentMigrations;
// Bulk actions menu states // Bulk actions menu states
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false); const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
const closeMenu = () => setIsMenuOpen(false); const closeMenu = () => setIsMenuOpen(false);
@ -248,7 +252,26 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
}, },
}, },
]; ];
if (agentMigrationsEnabled) {
menuItems.splice(1, 0, {
name: (
<FormattedMessage
id="xpack.fleet.agentBulkActions.bulkMigrateAgents"
data-test-subj="agentBulkActionsBulkMigrate"
defaultMessage="Migrate {agentCount, plural, one {# agent} other {# agents}}"
values={{
agentCount,
}}
/>
),
icon: <EuiIcon type="cluster" size="m" />,
disabled: !authz.fleet.allAgents || !agentMigrationsEnabled,
onClick: (event: any) => {
setIsMenuOpen(false);
onBulkMigrateClicked(selectedAgents);
},
});
}
const panels = [ const panels = [
{ {
id: 0, id: 0,

View file

@ -17,6 +17,9 @@ describe('MigrateAgentFlyout', () => {
let component: ReturnType<typeof renderer.render>; let component: ReturnType<typeof renderer.render>;
beforeEach(() => { beforeEach(() => {
// Reset the mocks before each test
jest.clearAllMocks();
component = renderer.render( component = renderer.render(
<AgentMigrateFlyout <AgentMigrateFlyout
onClose={jest.fn()} onClose={jest.fn()}
@ -32,6 +35,7 @@ describe('MigrateAgentFlyout', () => {
enrolled_at: new Date().toISOString(), enrolled_at: new Date().toISOString(),
}, },
]} ]}
protectedAndFleetAgents={[]}
/> />
); );
}); });
@ -60,4 +64,76 @@ describe('MigrateAgentFlyout', () => {
expect(submitButton).not.toBeDisabled(); expect(submitButton).not.toBeDisabled();
}); });
it('replace token button should be visible when there is one agent', () => {
const replaceTokenButton = component.getByTestId('migrateAgentFlyoutReplaceTokenButton');
expect(replaceTokenButton).toBeInTheDocument();
});
it('replace token button should not be visible when there is more than one agent', () => {
component.rerender(
<AgentMigrateFlyout
onClose={jest.fn()}
onSave={jest.fn()}
agents={[
{
active: true,
status: 'online',
local_metadata: { elastic: { agent: { version: '8.8.0' } } },
id: '1',
packages: [],
type: 'PERMANENT',
enrolled_at: new Date().toISOString(),
},
{
active: true,
status: 'online',
local_metadata: { elastic: { agent: { version: '8.8.0' } } },
id: '2',
packages: [],
type: 'PERMANENT',
enrolled_at: new Date().toISOString(),
},
]}
protectedAndFleetAgents={[]}
/>
);
const replaceTokenButton = component.queryByTestId('migrateAgentFlyoutReplaceTokenButton');
expect(replaceTokenButton).not.toBeInTheDocument();
});
it('alert panel should be visible and show protected and or fleet-server agents when there are any', () => {
component.rerender(
<AgentMigrateFlyout
onClose={jest.fn()}
onSave={jest.fn()}
agents={[
{
active: true,
status: 'online',
local_metadata: { elastic: { agent: { version: '8.8.0' } } },
id: '1',
packages: [],
type: 'PERMANENT',
enrolled_at: new Date().toISOString(),
},
]}
protectedAndFleetAgents={[
{
active: true,
status: 'online',
local_metadata: { elastic: { agent: { version: '8.8.0' } } },
id: '2',
packages: [],
type: 'PERMANENT',
enrolled_at: new Date().toISOString(),
},
]}
/>
);
const alertPanel = component.getByTestId('migrateAgentFlyoutAlertPanel');
expect(alertPanel).toBeInTheDocument();
});
it('alert panel should not be visible when there are no protected or fleet-server agents', () => {
const alertPanel = component.queryByTestId('migrateAgentFlyoutAlertPanel');
expect(alertPanel).not.toBeInTheDocument();
});
}); });

View file

@ -27,36 +27,53 @@ import {
EuiTextArea, EuiTextArea,
EuiSwitch, EuiSwitch,
EuiFlexItem, EuiFlexItem,
EuiIcon,
} from '@elastic/eui'; } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react'; import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import type { MigrateSingleAgentRequest } from '../../../../../../../../common/types'; import type {
MigrateSingleAgentRequest,
BulkMigrateAgentsRequest,
} from '../../../../../../../../common/types';
import type { Agent } from '../../../../../types'; import type { Agent } from '../../../../../types';
import { useMigrateSingleAgent, useStartServices } from '../../../../../hooks'; import {
useMigrateSingleAgent,
useBulkMigrateAgents,
useStartServices,
} from '../../../../../hooks';
import { HeadersInput } from './headers_input'; import { HeadersInput } from './headers_input';
interface Props { interface Props {
agents: Array<Agent | undefined>; agents: Agent[];
onClose: () => void; onClose: () => void;
onSave: () => void; onSave: () => void;
protectedAndFleetAgents: Agent[];
} }
export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave }) => { export const AgentMigrateFlyout: React.FC<Props> = ({
agents,
onClose,
onSave,
protectedAndFleetAgents,
}) => {
const { notifications } = useStartServices(); const { notifications } = useStartServices();
const migrateAgent = useMigrateSingleAgent; const migrateAgent = useMigrateSingleAgent;
const migrateAgents = useBulkMigrateAgents;
const [formValid, setFormValid] = React.useState(false); const [formValid, setFormValid] = React.useState(false);
const [validClusterURL, setValidClusterURL] = React.useState(false); const [validClusterURL, setValidClusterURL] = React.useState(false);
const [formContent, setFormContent] = React.useState<MigrateSingleAgentRequest['body']>({ const [formContent, setFormContent] = React.useState<
id: agents[0]?.id!, MigrateSingleAgentRequest['body'] | BulkMigrateAgentsRequest['body']
>({
id: '',
agents: [],
uri: '', uri: '',
enrollment_token: '', enrollment_token: '',
settings: {}, settings: {},
}); });
useEffect(() => { useEffect(() => {
const validateForm = () => { const validateForm = () => {
if (formContent.uri && formContent.enrollment_token && validClusterURL) { if (formContent.uri && formContent.enrollment_token && validClusterURL) {
@ -68,7 +85,7 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
const validateClusterURL = () => { const validateClusterURL = () => {
if (formContent.uri) { if (formContent.uri) {
// check that the uri matches a valid URI schema using zod // check that the uri matches a valid URI schema using URL constructor
try { try {
new URL(formContent.uri); new URL(formContent.uri);
setValidClusterURL(true); setValidClusterURL(true);
@ -86,7 +103,11 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
const submitForm = () => { const submitForm = () => {
try { try {
migrateAgent(formContent); if (agents.length === 1) {
migrateAgent({ ...formContent, id: agents[0].id });
} else {
migrateAgents({ ...formContent, agents: agents.map((agent) => agent.id) });
}
notifications.toasts.addSuccess({ notifications.toasts.addSuccess({
title: i18n.translate('xpack.fleet.agentList.migrateAgentFlyout.successNotificationTitle', { title: i18n.translate('xpack.fleet.agentList.migrateAgentFlyout.successNotificationTitle', {
defaultMessage: 'Agent migration initiated', defaultMessage: 'Agent migration initiated',
@ -116,13 +137,16 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
return ( return (
<> <>
<EuiFlyout data-test-subj="migrateAgentFlyout" size="s" onClose={onClose}> <EuiFlyout data-test-subj="migrateAgentFlyout" onClose={onClose}>
<EuiFlyoutHeader hasBorder> <EuiFlyoutHeader hasBorder>
<EuiTitle size="l"> <EuiTitle size="l">
<h1> <h1>
<FormattedMessage <FormattedMessage
id="xpack.fleet.agentList.migrateAgentFlyout.title" id="xpack.fleet.agentList.migrateAgentFlyout.title"
defaultMessage="Migrate Agent" defaultMessage="Migrate {agentCount, plural, one {agent} other {agents}}"
values={{
agentCount: agents.length,
}}
/> />
</h1> </h1>
</EuiTitle> </EuiTitle>
@ -130,9 +154,53 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
<EuiText> <EuiText>
<FormattedMessage <FormattedMessage
id="xpack.fleet.agentList.migrateAgentFlyout.title" id="xpack.fleet.agentList.migrateAgentFlyout.title"
defaultMessage="Move this Elastic Agent to a different Fleet server by specifying a new cluster URL and enrollment token." defaultMessage="Move {agentCount, plural, one {this agent} other {these agents}} to a different Fleet Server by specifying a new cluster URL and enrollment token."
values={{
agentCount: agents.length,
}}
/> />
</EuiText> </EuiText>
{protectedAndFleetAgents.length > 0 && (
<>
<EuiSpacer />
<EuiPanel color="warning" data-test-subj="migrateAgentFlyoutAlertPanel">
<EuiText color="warning" className="eui-alignMiddle">
<FormattedMessage
id="xpack.fleet.agentList.migrateAgentFlyout.warning"
defaultMessage="{icon} {x} of {y} selected agents cannot be migrated as they are tamper protected or Fleet Server agents."
values={{
icon: <EuiIcon type="warning" />,
x: protectedAndFleetAgents.length,
y: agents.length + protectedAndFleetAgents.length,
}}
/>
</EuiText>
<EuiAccordion
id="migrateAgentFlyoutWarningAccordion"
buttonContent={
<EuiButtonEmpty onClick={() => {}}>
<FormattedMessage
id="xpack.fleet.agentList.migrateAgentFlyout.warningAccordion"
defaultMessage="View Hosts"
/>
</EuiButtonEmpty>
}
initialIsOpen={false}
>
<EuiSpacer size="s" />
<EuiText>
<ul>
{protectedAndFleetAgents.map((agent) => (
<li key={agent.id}>{agent.local_metadata?.host?.hostname}</li>
))}
</ul>
</EuiText>
</EuiAccordion>
</EuiPanel>
</>
)}
</EuiFlyoutHeader> </EuiFlyoutHeader>
<EuiFlyoutBody> <EuiFlyoutBody>
<EuiForm> <EuiForm>
@ -202,7 +270,7 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
<EuiSpacer size="m" /> <EuiSpacer size="m" />
{/* Additional Settings Section */} {/* Additional Settings Section */}
<EuiFormRow> <EuiFormRow fullWidth>
<EuiAccordion <EuiAccordion
arrowDisplay="right" arrowDisplay="right"
id="migrateAgentFlyoutAdditionalOptions" id="migrateAgentFlyoutAdditionalOptions"
@ -238,8 +306,9 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
</EuiText> </EuiText>
<EuiSpacer size="m" /> <EuiSpacer size="m" />
<EuiFormRow label="ca_sha256"> <EuiFormRow label="ca_sha256" fullWidth>
<EuiFieldText <EuiFieldText
fullWidth
onChange={(e) => onChange={(e) =>
setFormContent({ setFormContent({
...formContent, ...formContent,
@ -255,8 +324,10 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
defaultMessage="Certificate Authorities" defaultMessage="Certificate Authorities"
/> />
} }
fullWidth
> >
<EuiFieldText <EuiFieldText
fullWidth
onChange={(e) => onChange={(e) =>
setFormContent({ setFormContent({
...formContent, ...formContent,
@ -275,6 +346,7 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
defaultMessage="Elastic Agent Certificate" defaultMessage="Elastic Agent Certificate"
/> />
} }
fullWidth
> >
<EuiTextArea <EuiTextArea
onChange={(e) => onChange={(e) =>
@ -296,6 +368,7 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
defaultMessage="Elastic Agent Certificate Key" defaultMessage="Elastic Agent Certificate Key"
/> />
} }
fullWidth
> >
<EuiTextArea <EuiTextArea
onChange={(e) => onChange={(e) =>
@ -339,6 +412,7 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
defaultMessage="Headers" defaultMessage="Headers"
/> />
} }
fullWidth
> >
<HeadersInput <HeadersInput
headers={formContent.settings?.headers || {}} headers={formContent.settings?.headers || {}}
@ -360,6 +434,7 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
defaultMessage="Proxy Headers" defaultMessage="Proxy Headers"
/> />
} }
fullWidth
> >
<HeadersInput <HeadersInput
headers={formContent.settings?.proxy_headers || {}} headers={formContent.settings?.proxy_headers || {}}
@ -403,15 +478,16 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
defaultMessage="Proxy URL" defaultMessage="Proxy URL"
/> />
} }
fullWidth
> >
<EuiFieldText <EuiFieldText
fullWidth
onChange={(e) => onChange={(e) =>
setFormContent({ setFormContent({
...formContent, ...formContent,
settings: { ...formContent.settings, proxy_url: e.target.value }, settings: { ...formContent.settings, proxy_url: e.target.value },
}) })
} }
fullWidth
/> />
</EuiFormRow> </EuiFormRow>
</EuiAccordion> </EuiAccordion>
@ -437,7 +513,7 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
/> />
</EuiText> </EuiText>
<EuiSpacer size="m" /> <EuiSpacer size="m" />
<EuiFormRow> <EuiFormRow fullWidth>
<EuiFlexGroup alignItems="flexStart"> <EuiFlexGroup alignItems="flexStart">
<EuiFlexItem> <EuiFlexItem>
<EuiSwitch <EuiSwitch
@ -475,7 +551,7 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
</EuiFlexItem> </EuiFlexItem>
</EuiFlexGroup> </EuiFlexGroup>
</EuiFormRow> </EuiFormRow>
<EuiFormRow> <EuiFormRow fullWidth>
<EuiFlexGroup justifyContent="spaceBetween"> <EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem> <EuiFlexItem>
<EuiSwitch <EuiSwitch
@ -497,26 +573,37 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
} }
/> />
</EuiFlexItem> </EuiFlexItem>
<EuiFlexItem> {/* Replace token shouldnt be an option when bulk migrating */}
<EuiSwitch {agents.length === 1 && (
label={ <EuiFlexItem>
<FormattedMessage <EuiSwitch
id="xpack.fleet.agentList.migrateAgentFlyout.replaceTokenLabel" data-test-subj="migrateAgentFlyoutReplaceTokenButton"
defaultMessage="Replace Token" label={
/> <FormattedMessage
} id="xpack.fleet.agentList.migrateAgentFlyout.replaceTokenLabel"
checked={formContent.settings?.replace_token ?? false} defaultMessage="Replace Token"
onChange={(e) => />
setFormContent({ }
...formContent, checked={
settings: { (
...formContent.settings, formContent.settings as MigrateSingleAgentRequest['body']['settings']
replace_token: e.target.checked, )?.replace_token ?? false
}, }
}) onChange={(e) => {
} // Only allow setting replace_token when migrating a single agent
/> if ('id' in formContent) {
</EuiFlexItem> setFormContent({
...formContent,
settings: {
...formContent.settings,
replace_token: e.target.checked,
},
});
}
}}
/>
</EuiFlexItem>
)}
</EuiFlexGroup> </EuiFlexGroup>
</EuiFormRow> </EuiFormRow>
</EuiAccordion> </EuiAccordion>
@ -541,7 +628,8 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
> >
<FormattedMessage <FormattedMessage
id="xpack.fleet.agentList.migrateAgentFlyout.submitButtonLabel" id="xpack.fleet.agentList.migrateAgentFlyout.submitButtonLabel"
defaultMessage="Migrate Agent" defaultMessage="Migrate {agentCount, plural, one {# agent} other {# agents}}"
values={{ agentCount: agents.length }}
/> />
</EuiButton> </EuiButton>
</EuiFlexGroup> </EuiFlexGroup>

View file

@ -63,6 +63,7 @@ export interface SearchAndFilterBarProps {
latestAgentActionErrors: number; latestAgentActionErrors: number;
sortField?: string; sortField?: string;
sortOrder?: 'asc' | 'desc'; sortOrder?: 'asc' | 'desc';
onBulkMigrateClicked: (agents: Agent[]) => void;
} }
export const SearchAndFilterBar: React.FunctionComponent<SearchAndFilterBarProps> = ({ export const SearchAndFilterBar: React.FunctionComponent<SearchAndFilterBarProps> = ({
@ -94,6 +95,7 @@ export const SearchAndFilterBar: React.FunctionComponent<SearchAndFilterBarProps
latestAgentActionErrors, latestAgentActionErrors,
sortField, sortField,
sortOrder, sortOrder,
onBulkMigrateClicked,
}) => { }) => {
const authz = useAuthz(); const authz = useAuthz();
@ -229,6 +231,7 @@ export const SearchAndFilterBar: React.FunctionComponent<SearchAndFilterBarProps
agentPolicies={agentPolicies} agentPolicies={agentPolicies}
sortField={sortField} sortField={sortField}
sortOrder={sortOrder} sortOrder={sortOrder}
onBulkMigrateClicked={(agents: Agent[]) => onBulkMigrateClicked(agents)}
/> />
</EuiFlexItem> </EuiFlexItem>
) : null} ) : null}

View file

@ -9,6 +9,7 @@ import { differenceBy, isEqual } from 'lodash';
import { EuiSpacer, EuiPortal } from '@elastic/eui'; import { EuiSpacer, EuiPortal } from '@elastic/eui';
import { isStuckInUpdating } from '../../../../../../common/services/agent_status'; import { isStuckInUpdating } from '../../../../../../common/services/agent_status';
import { FLEET_SERVER_PACKAGE } from '../../../../../../common';
import type { Agent } from '../../../types'; import type { Agent } from '../../../types';
@ -86,7 +87,8 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
const [showAgentActivityTour, setShowAgentActivityTour] = useState({ isOpen: false }); const [showAgentActivityTour, setShowAgentActivityTour] = useState({ isOpen: false });
// migrateAgentState // migrateAgentState
const [agentToMigrate, setAgentToMigrate] = useState<Agent | undefined>(undefined); const [agentsToMigrate, setAgentsToMigrate] = useState<Agent[] | undefined>(undefined);
const [protectedAndFleetAgents, setProtectedAndFleetAgents] = useState<Agent[]>([]);
const [migrateFlyoutOpen, setMigrateFlyoutOpen] = useState(false); const [migrateFlyoutOpen, setMigrateFlyoutOpen] = useState(false);
const { const {
@ -179,8 +181,19 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
setSortOrder(sort!.direction); setSortOrder(sort!.direction);
}; };
const openMigrateFlyout = (agent: Agent) => { const openMigrateFlyout = (agents: Agent[]) => {
setAgentToMigrate(agent); const protectedAgents = agents.filter(
(agent) => agentPoliciesIndexedById[agent.policy_id as string]?.is_protected
);
const fleetAgents = agents.filter((agent) =>
agentPoliciesIndexedById[agent.policy_id as string]?.package_policies?.some(
(p) => p.package?.name === FLEET_SERVER_PACKAGE
)
);
const unallowedAgents = [...protectedAgents, ...fleetAgents];
setProtectedAndFleetAgents(unallowedAgents);
setAgentsToMigrate(agents.filter((agent) => !unallowedAgents.some((a) => a.id === agent.id)));
setMigrateFlyoutOpen(true); setMigrateFlyoutOpen(true);
}; };
@ -206,7 +219,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
}} }}
onGetUninstallCommandClick={() => setAgentToGetUninstallCommand(agent)} onGetUninstallCommandClick={() => setAgentToGetUninstallCommand(agent)}
onRequestDiagnosticsClick={() => setAgentToRequestDiagnostics(agent)} onRequestDiagnosticsClick={() => setAgentToRequestDiagnostics(agent)}
onMigrateAgentClick={() => openMigrateFlyout(agent)} onMigrateAgentClick={() => openMigrateFlyout([agent])}
/> />
); );
}; };
@ -410,13 +423,16 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
{migrateFlyoutOpen && ( {migrateFlyoutOpen && (
<EuiPortal> <EuiPortal>
<AgentMigrateFlyout <AgentMigrateFlyout
agents={[agentToMigrate]} agents={agentsToMigrate ?? []}
protectedAndFleetAgents={protectedAndFleetAgents ?? []}
onClose={() => { onClose={() => {
setAgentToMigrate(undefined); setAgentsToMigrate(undefined);
setProtectedAndFleetAgents([]);
setMigrateFlyoutOpen(false); setMigrateFlyoutOpen(false);
}} }}
onSave={() => { onSave={() => {
setAgentToMigrate(undefined); setAgentsToMigrate(undefined);
setProtectedAndFleetAgents([]);
setMigrateFlyoutOpen(false); setMigrateFlyoutOpen(false);
refreshAgents(); refreshAgents();
}} }}
@ -475,6 +491,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
latestAgentActionErrors={latestAgentActionErrors.length} latestAgentActionErrors={latestAgentActionErrors.length}
sortField={sortField} sortField={sortField}
sortOrder={sortOrder} sortOrder={sortOrder}
onBulkMigrateClicked={(agents: Agent[]) => openMigrateFlyout(agents)}
/> />
<EuiSpacer size="m" /> <EuiSpacer size="m" />
{/* Agent total, bulk actions and status bar */} {/* Agent total, bulk actions and status bar */}

View file

@ -21,6 +21,8 @@ import type {
UpdateAgentRequest, UpdateAgentRequest,
MigrateSingleAgentRequest, MigrateSingleAgentRequest,
MigrateSingleAgentResponse, MigrateSingleAgentResponse,
BulkMigrateAgentsRequest,
BulkMigrateAgentsResponse,
} from '../../../common/types'; } from '../../../common/types';
import { API_VERSIONS } from '../../../common/constants'; import { API_VERSIONS } from '../../../common/constants';
@ -393,3 +395,17 @@ export function useMigrateSingleAgent(options: MigrateSingleAgentRequest['body']
}, },
}); });
} }
export function useBulkMigrateAgents(options: BulkMigrateAgentsRequest['body']) {
return sendRequest<BulkMigrateAgentsResponse>({
path: agentRouteService.postBulkMigrateAgents(),
method: 'post',
version: API_VERSIONS.public.v1,
body: {
agents: options.agents,
uri: options.uri,
enrollment_token: options.enrollment_token,
settings: options.settings ?? {},
},
});
}