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,
postMigrateSingleAgent: (agentId: string) =>
AGENT_API_ROUTES.MIGRATE_PATTERN.replace('{agentId}', agentId),
postBulkMigrateAgents: () => AGENT_API_ROUTES.BULK_MIGRATE_PATTERN,
};
export const outputRoutesService = {

View file

@ -207,6 +207,30 @@ export interface MigrateSingleAgentRequest {
export interface MigrateSingleAgentResponse {
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 {
params: {
agentId: string;

View file

@ -49,7 +49,9 @@ const defaultProps = {
describe('AgentBulkActions', () => {
beforeAll(() => {
mockedExperimentalFeaturesService.get.mockReturnValue({} as any);
mockedExperimentalFeaturesService.get.mockReturnValue({
enableAgentMigrations: true,
} as any);
jest.mocked(useAuthz).mockReturnValue({
fleet: {
allAgents: true,
@ -93,6 +95,7 @@ describe('AgentBulkActions', () => {
expect(
results.getByText('Request diagnostics for 2 agents').closest('button')!
).toBeEnabled();
expect(results.getByText('Migrate 2 agents').closest('button')!).toBeEnabled();
});
it('should allow scheduled upgrades if the license allows it', async () => {
@ -210,5 +213,19 @@ describe('AgentBulkActions', () => {
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';
import { FormattedMessage } from '@kbn/i18n-react';
import { ExperimentalFeaturesService } from '../../../../services';
import type { Agent, AgentPolicy } from '../../../../types';
import {
AgentReassignAgentPolicyModal,
@ -49,6 +51,7 @@ export interface Props {
agentPolicies: AgentPolicy[];
sortField?: string;
sortOrder?: 'asc' | 'desc';
onBulkMigrateClicked: (agents: Agent[]) => void;
}
export const AgentBulkActions: React.FunctionComponent<Props> = ({
@ -63,11 +66,12 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
agentPolicies,
sortField,
sortOrder,
onBulkMigrateClicked,
}) => {
const licenseService = useLicense();
const authz = useAuthz();
const isLicenceAllowingScheduleUpgrade = licenseService.hasAtLeast(LICENSE_FOR_SCHEDULE_UPGRADE);
const agentMigrationsEnabled = ExperimentalFeaturesService.get().enableAgentMigrations;
// Bulk actions menu states
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(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 = [
{
id: 0,

View file

@ -17,6 +17,9 @@ describe('MigrateAgentFlyout', () => {
let component: ReturnType<typeof renderer.render>;
beforeEach(() => {
// Reset the mocks before each test
jest.clearAllMocks();
component = renderer.render(
<AgentMigrateFlyout
onClose={jest.fn()}
@ -32,6 +35,7 @@ describe('MigrateAgentFlyout', () => {
enrolled_at: new Date().toISOString(),
},
]}
protectedAndFleetAgents={[]}
/>
);
});
@ -60,4 +64,76 @@ describe('MigrateAgentFlyout', () => {
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,
EuiSwitch,
EuiFlexItem,
EuiIcon,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import type { MigrateSingleAgentRequest } from '../../../../../../../../common/types';
import type {
MigrateSingleAgentRequest,
BulkMigrateAgentsRequest,
} from '../../../../../../../../common/types';
import type { Agent } from '../../../../../types';
import { useMigrateSingleAgent, useStartServices } from '../../../../../hooks';
import {
useMigrateSingleAgent,
useBulkMigrateAgents,
useStartServices,
} from '../../../../../hooks';
import { HeadersInput } from './headers_input';
interface Props {
agents: Array<Agent | undefined>;
agents: Agent[];
onClose: () => 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 migrateAgent = useMigrateSingleAgent;
const migrateAgents = useBulkMigrateAgents;
const [formValid, setFormValid] = React.useState(false);
const [validClusterURL, setValidClusterURL] = React.useState(false);
const [formContent, setFormContent] = React.useState<MigrateSingleAgentRequest['body']>({
id: agents[0]?.id!,
const [formContent, setFormContent] = React.useState<
MigrateSingleAgentRequest['body'] | BulkMigrateAgentsRequest['body']
>({
id: '',
agents: [],
uri: '',
enrollment_token: '',
settings: {},
});
useEffect(() => {
const validateForm = () => {
if (formContent.uri && formContent.enrollment_token && validClusterURL) {
@ -68,7 +85,7 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
const validateClusterURL = () => {
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 {
new URL(formContent.uri);
setValidClusterURL(true);
@ -86,7 +103,11 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
const submitForm = () => {
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({
title: i18n.translate('xpack.fleet.agentList.migrateAgentFlyout.successNotificationTitle', {
defaultMessage: 'Agent migration initiated',
@ -116,13 +137,16 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
return (
<>
<EuiFlyout data-test-subj="migrateAgentFlyout" size="s" onClose={onClose}>
<EuiFlyout data-test-subj="migrateAgentFlyout" onClose={onClose}>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="l">
<h1>
<FormattedMessage
id="xpack.fleet.agentList.migrateAgentFlyout.title"
defaultMessage="Migrate Agent"
defaultMessage="Migrate {agentCount, plural, one {agent} other {agents}}"
values={{
agentCount: agents.length,
}}
/>
</h1>
</EuiTitle>
@ -130,9 +154,53 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
<EuiText>
<FormattedMessage
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>
{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>
<EuiFlyoutBody>
<EuiForm>
@ -202,7 +270,7 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
<EuiSpacer size="m" />
{/* Additional Settings Section */}
<EuiFormRow>
<EuiFormRow fullWidth>
<EuiAccordion
arrowDisplay="right"
id="migrateAgentFlyoutAdditionalOptions"
@ -238,8 +306,9 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
</EuiText>
<EuiSpacer size="m" />
<EuiFormRow label="ca_sha256">
<EuiFormRow label="ca_sha256" fullWidth>
<EuiFieldText
fullWidth
onChange={(e) =>
setFormContent({
...formContent,
@ -255,8 +324,10 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
defaultMessage="Certificate Authorities"
/>
}
fullWidth
>
<EuiFieldText
fullWidth
onChange={(e) =>
setFormContent({
...formContent,
@ -275,6 +346,7 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
defaultMessage="Elastic Agent Certificate"
/>
}
fullWidth
>
<EuiTextArea
onChange={(e) =>
@ -296,6 +368,7 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
defaultMessage="Elastic Agent Certificate Key"
/>
}
fullWidth
>
<EuiTextArea
onChange={(e) =>
@ -339,6 +412,7 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
defaultMessage="Headers"
/>
}
fullWidth
>
<HeadersInput
headers={formContent.settings?.headers || {}}
@ -360,6 +434,7 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
defaultMessage="Proxy Headers"
/>
}
fullWidth
>
<HeadersInput
headers={formContent.settings?.proxy_headers || {}}
@ -403,15 +478,16 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
defaultMessage="Proxy URL"
/>
}
fullWidth
>
<EuiFieldText
fullWidth
onChange={(e) =>
setFormContent({
...formContent,
settings: { ...formContent.settings, proxy_url: e.target.value },
})
}
fullWidth
/>
</EuiFormRow>
</EuiAccordion>
@ -437,7 +513,7 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
/>
</EuiText>
<EuiSpacer size="m" />
<EuiFormRow>
<EuiFormRow fullWidth>
<EuiFlexGroup alignItems="flexStart">
<EuiFlexItem>
<EuiSwitch
@ -475,7 +551,7 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
<EuiFormRow>
<EuiFormRow fullWidth>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiSwitch
@ -497,26 +573,37 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
}
/>
</EuiFlexItem>
{/* Replace token shouldnt be an option when bulk migrating */}
{agents.length === 1 && (
<EuiFlexItem>
<EuiSwitch
data-test-subj="migrateAgentFlyoutReplaceTokenButton"
label={
<FormattedMessage
id="xpack.fleet.agentList.migrateAgentFlyout.replaceTokenLabel"
defaultMessage="Replace Token"
/>
}
checked={formContent.settings?.replace_token ?? false}
onChange={(e) =>
checked={
(
formContent.settings as MigrateSingleAgentRequest['body']['settings']
)?.replace_token ?? false
}
onChange={(e) => {
// Only allow setting replace_token when migrating a single agent
if ('id' in formContent) {
setFormContent({
...formContent,
settings: {
...formContent.settings,
replace_token: e.target.checked,
},
})
});
}
}}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFormRow>
</EuiAccordion>
@ -541,7 +628,8 @@ export const AgentMigrateFlyout: React.FC<Props> = ({ agents, onClose, onSave })
>
<FormattedMessage
id="xpack.fleet.agentList.migrateAgentFlyout.submitButtonLabel"
defaultMessage="Migrate Agent"
defaultMessage="Migrate {agentCount, plural, one {# agent} other {# agents}}"
values={{ agentCount: agents.length }}
/>
</EuiButton>
</EuiFlexGroup>

View file

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

View file

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

View file

@ -21,6 +21,8 @@ import type {
UpdateAgentRequest,
MigrateSingleAgentRequest,
MigrateSingleAgentResponse,
BulkMigrateAgentsRequest,
BulkMigrateAgentsResponse,
} from '../../../common/types';
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 ?? {},
},
});
}