mirror of
https://github.com/elastic/kibana.git
synced 2025-06-28 11:05:39 -04:00
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:
parent
fddf9f45d9
commit
c868136f48
9 changed files with 313 additions and 48 deletions
|
@ -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 = {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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 */}
|
||||||
|
|
|
@ -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 ?? {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue