[Fleet][UI] New flyout with signed uninstall command (#152886)

## Summary

Adds new flyout which contains uninstall commands for Agents.

🗒️  Todo for this PR:
- [x] update copies
- [x] add tests

Todo for follow up PRs:
- update API call with real API
- make general for Defend integration uninstall commands

### Screenshots

#### From Agents:
<img
src="https://user-images.githubusercontent.com/39014407/223678946-de135d46-ee81-4b91-8102-bf3eae93a450.png"
width="350px" /> <img
src="https://user-images.githubusercontent.com/39014407/223678983-c94aac80-5645-4906-a4b8-998b80099de5.png"
width="350px" />


#### From Agent Policies:
<img
src="https://user-images.githubusercontent.com/39014407/223679046-f2208cd6-bb17-42b4-bd9e-dc05adefe971.png"
width="350px" /> <img
src="https://user-images.githubusercontent.com/39014407/223679089-efb6d74b-8d37-449b-8d72-08f044dc1084.png"
width="350px" />


#### Also, it is accessible from Policy Details, too
<img
src="https://user-images.githubusercontent.com/39014407/230444814-1329ba7e-6b2e-46e2-875c-8be8a84e05dc.png"
width="350px" /> <img
src="https://user-images.githubusercontent.com/39014407/230444849-8b1a93e9-b38d-4c01-bb44-75bbb99554c8.png"
width="350px" />


### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Gergő Ábrahám 2023-05-12 12:35:25 +02:00 committed by GitHub
parent dec3227d3d
commit 3c4276e38b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 686 additions and 75 deletions

View file

@ -702,6 +702,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => {
secureLogstash: `${FLEET_DOCS}secure-logstash-connections.html`,
agentPolicy: `${FLEET_DOCS}agent-policy.html`,
api: `${FLEET_DOCS}fleet-api-docs.html`,
uninstallAgent: `${SECURITY_SOLUTION_DOCS}uninstall-agent.html`,
},
ecs: {
guide: `${ELASTIC_WEBSITE_URL}guide/en/ecs/current/index.html`,

View file

@ -472,6 +472,7 @@ export interface DocLinks {
secureLogstash: string;
agentPolicy: string;
api: string;
uninstallAgent: string;
}>;
readonly ecs: {
readonly guide: string;

View file

@ -15,7 +15,6 @@ import { I18nProvider } from '@kbn/i18n-react';
import { CoreScopedHistory } from '@kbn/core/public';
import { getStorybookContextProvider } from '@kbn/custom-integrations-plugin/storybook';
import { guidedOnboardingMock } from '@kbn/guided-onboarding-plugin/public/mocks';
import { IntegrationsAppContext } from '../../public/applications/integrations/app';
import type { FleetConfigType, FleetStartServices } from '../../public/plugin';
@ -111,7 +110,7 @@ export const StorybookContext: React.FC<{ storyContext?: Parameters<DecoratorFn>
writeIntegrationPolicies: true,
},
},
guidedOnboarding: guidedOnboardingMock.createStart(),
guidedOnboarding: {},
}),
[isCloudEnabled]
);

View file

@ -11,9 +11,15 @@ import { EuiContextMenuItem, EuiPortal } from '@elastic/eui';
import type { AgentPolicy } from '../../../types';
import { useAuthz } from '../../../hooks';
import { AgentEnrollmentFlyout, ContextMenuActions } from '../../../components';
import {
AgentEnrollmentFlyout,
ContextMenuActions,
UninstallCommandFlyout,
} from '../../../components';
import { FLEET_SERVER_PACKAGE } from '../../../constants';
import { ExperimentalFeaturesService } from '../../../../../services/experimental_features';
import { AgentPolicyYamlFlyout } from './agent_policy_yaml_flyout';
import { AgentPolicyCopyProvider } from './agent_policy_copy_provider';
@ -36,6 +42,10 @@ export const AgentPolicyActionMenu = memo<{
const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState<boolean>(
enrollmentFlyoutOpenByDefault
);
const [isUninstallCommandFlyoutOpen, setIsUninstallCommandFlyoutOpen] =
useState<boolean>(false);
const { agentTamperProtectionEnabled } = ExperimentalFeaturesService.get();
const isFleetServerPolicy = useMemo(
() =>
@ -120,21 +130,48 @@ export const AgentPolicyActionMenu = memo<{
/>
</EuiContextMenuItem>,
];
if (agentTamperProtectionEnabled && !agentPolicy?.is_managed) {
menuItems.push(
<EuiContextMenuItem
icon="minusInCircle"
onClick={() => {
setIsContextMenuOpen(false);
setIsUninstallCommandFlyoutOpen(true);
}}
key="getUninstallCommand"
data-test-subj="uninstall-agents-command-menu-item"
>
<FormattedMessage
id="xpack.fleet.agentPolicyActionMenu.getUninstallCommand"
defaultMessage="Uninstall agents on this policy"
/>
</EuiContextMenuItem>
);
}
return (
<>
{isYamlFlyoutOpen ? (
{isYamlFlyoutOpen && (
<EuiPortal>
<AgentPolicyYamlFlyout
policyId={agentPolicy.id}
onClose={() => setIsYamlFlyoutOpen(false)}
/>
</EuiPortal>
) : null}
)}
{isEnrollmentFlyoutOpen && (
<EuiPortal>
<AgentEnrollmentFlyout agentPolicy={agentPolicy} onClose={onClose} />
</EuiPortal>
)}
{isUninstallCommandFlyoutOpen && (
<UninstallCommandFlyout
target="agent"
policyId={agentPolicy.id}
onClose={() => setIsUninstallCommandFlyoutOpen(false)}
/>
)}
<ContextMenuActions
isOpen={isContextMenuOpen}
onChange={onContextMenuChange}

View file

@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { RenderResult } from '@testing-library/react';
import { fireEvent, waitFor } from '@testing-library/react';
import { allowedExperimentalValues } from '../../../../../../common/experimental_features';
import { ExperimentalFeaturesService } from '../../../../../services';
import { createFleetTestRendererMock } from '../../../../../mock';
import type { GetAgentPoliciesResponse } from '../../../../../../common';
import { AgentPolicyListPage } from '.';
jest.mock('../../../hooks', () => ({
...jest.requireActual('../../../hooks'),
useGetAgentPolicies: jest.fn().mockReturnValue({
data: {
items: [
{ id: 'not_managed_policy', is_managed: false, updated_at: '2023-04-06T07:19:29.892Z' },
{ id: 'managed_policy', is_managed: true, updated_at: '2023-04-07T07:19:29.892Z' },
],
total: 2,
} as GetAgentPoliciesResponse,
isLoading: false,
resendRequest: jest.fn(),
}),
}));
describe('AgentPolicyListPage', () => {
let renderResult: RenderResult;
const render = () => {
const renderer = createFleetTestRendererMock();
// todo: this can be removed when agentTamperProtectionEnabled feature flag is enabled/deleted
ExperimentalFeaturesService.init({
...allowedExperimentalValues,
agentTamperProtectionEnabled: true,
});
return renderer.render(<AgentPolicyListPage />);
};
beforeEach(async () => {
renderResult = render();
await waitFor(() => {
expect(renderResult.queryByText(/Loading agent policies/)).not.toBeInTheDocument();
expect(renderResult.queryByText(/No agent policies/)).not.toBeInTheDocument();
});
});
describe('Uninstall command flyout', () => {
it('should not render "Uninstall agents on this policy" menu item for managed Agent', async () => {
expect(
renderResult.queryByTestId('uninstall-agents-command-menu-item')
).not.toBeInTheDocument();
fireEvent.click(renderResult.getAllByTestId('agentActionsBtn')[1]);
expect(
renderResult.queryByTestId('uninstall-agents-command-menu-item')
).not.toBeInTheDocument();
});
it('should render "Uninstall agents on this policy" menu item for not managed Agent', async () => {
expect(
renderResult.queryByTestId('uninstall-agents-command-menu-item')
).not.toBeInTheDocument();
fireEvent.click(renderResult.getAllByTestId('agentActionsBtn')[0]);
expect(renderResult.queryByTestId('uninstall-agents-command-menu-item')).toBeInTheDocument();
});
it('should open uninstall commands flyout when clicking on "Uninstall agents on this policy"', () => {
fireEvent.click(renderResult.getAllByTestId('agentActionsBtn')[0]);
expect(renderResult.queryByTestId('uninstall-command-flyout')).not.toBeInTheDocument();
fireEvent.click(renderResult.getByTestId('uninstall-agents-command-menu-item'));
expect(renderResult.queryByTestId('uninstall-command-flyout')).toBeInTheDocument();
});
});
});

View file

@ -34,7 +34,8 @@ import {
useUrlParams,
useBreadcrumbs,
} from '../../../hooks';
import { AgentPolicySummaryLine, SearchBar } from '../../../components';
import { SearchBar } from '../../../components';
import { AgentPolicySummaryLine } from '../../../../../components';
import { LinkedAgentCount, AgentPolicyActionMenu } from '../components';
import { CreateAgentPolicyFlyout } from './components';

View file

@ -38,7 +38,8 @@ function renderTableRowActions({
onReassignClick={jest.fn()}
onRequestDiagnosticsClick={jest.fn()}
onUnenrollClick={jest.fn()}
onUpgradeClick={jest.fn}
onUpgradeClick={jest.fn()}
onGetUninstallCommandClick={jest.fn()}
/>
);

View file

@ -22,6 +22,7 @@ export const TableRowActions: React.FunctionComponent<{
agentPolicy?: AgentPolicy;
onReassignClick: () => void;
onUnenrollClick: () => void;
onGetUninstallCommandClick: () => void;
onUpgradeClick: () => void;
onAddRemoveTagsClick: (button: HTMLElement) => void;
onRequestDiagnosticsClick: () => void;
@ -30,6 +31,7 @@ export const TableRowActions: React.FunctionComponent<{
agentPolicy,
onReassignClick,
onUnenrollClick,
onGetUninstallCommandClick,
onUpgradeClick,
onAddRemoveTagsClick,
onRequestDiagnosticsClick,
@ -40,7 +42,8 @@ export const TableRowActions: React.FunctionComponent<{
const isUnenrolling = agent.status === 'unenrolling';
const kibanaVersion = useKibanaVersion();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { diagnosticFileUploadEnabled } = ExperimentalFeaturesService.get();
const { diagnosticFileUploadEnabled, agentTamperProtectionEnabled } =
ExperimentalFeaturesService.get();
const menuItems = [
<EuiContextMenuItem
icon="inspect"
@ -113,6 +116,26 @@ export const TableRowActions: React.FunctionComponent<{
/>
</EuiContextMenuItem>
);
if (agentTamperProtectionEnabled) {
menuItems.push(
<EuiContextMenuItem
icon="minusInCircle"
onClick={() => {
onGetUninstallCommandClick();
setIsMenuOpen(false);
}}
disabled={!agent.active}
key="getUninstallCommand"
data-test-subj="uninstallAgentMenuItem"
>
<FormattedMessage
id="xpack.fleet.agentList.getUninstallCommand"
defaultMessage="Uninstall agent"
/>
</EuiContextMenuItem>
);
}
}
if (diagnosticFileUploadEnabled) {

View file

@ -10,8 +10,10 @@ import React from 'react';
import type { RenderResult } from '@testing-library/react';
import { act, fireEvent, waitFor } from '@testing-library/react';
import { allowedExperimentalValues } from '../../../../../../common/experimental_features';
import { ExperimentalFeaturesService } from '../../../../../services';
import type { GetAgentPoliciesResponse } from '../../../../../../common';
import { createFleetTestRendererMock } from '../../../../../mock';
import { sendGetAgents, sendGetAgentStatus } from '../../../hooks';
import { AgentListPage } from '.';
@ -28,7 +30,12 @@ jest.mock('../../../hooks', () => ({
},
sendGetAgents: jest.fn(),
useGetAgentPolicies: jest.fn().mockReturnValue({
data: { items: [{ id: 'policy1' }] },
data: {
items: [
{ id: 'policy1', is_managed: false },
{ id: 'managed_policy', is_managed: true },
],
} as GetAgentPoliciesResponse,
isLoading: false,
resendRequest: jest.fn(),
}),
@ -94,18 +101,63 @@ describe('agent_list_page', () => {
let utils: RenderResult;
beforeEach(async () => {
mockedSendGetAgents
.mockResolvedValueOnce({
data: {
items: mapAgents(['agent1', 'agent2', 'agent3', 'agent4', 'agent5']),
total: 6,
statusSummary: {
online: 6,
describe('handling slow agent status request', () => {
beforeEach(async () => {
mockedSendGetAgents
.mockResolvedValueOnce({
data: {
items: mapAgents(['agent1', 'agent2', 'agent3', 'agent4', 'agent5']),
total: 6,
statusSummary: {
online: 6,
},
},
},
})
.mockResolvedValueOnce({
})
.mockResolvedValueOnce({
data: {
items: mapAgents(['agent1', 'agent2', 'agent3', 'agent4', 'agent6']),
total: 6,
statusSummary: {
online: 6,
},
},
});
jest.useFakeTimers({ legacyFakeTimers: true });
});
afterEach(() => {
jest.useRealTimers();
});
it('should not send another agents status request if first one takes longer', () => {
mockedSendGetAgentStatus.mockImplementation(async () => {
const sleep = () => {
return new Promise((res) => {
setTimeout(() => res({}), 35000);
});
};
await sleep();
return {
data: {
results: {
inactive: 0,
},
},
};
});
({ utils } = renderAgentList());
act(() => {
jest.advanceTimersByTime(65000);
});
expect(mockedSendGetAgentStatus).toHaveBeenCalledTimes(1);
});
});
describe('selection change', () => {
beforeEach(async () => {
mockedSendGetAgents.mockResolvedValue({
data: {
items: mapAgents(['agent1', 'agent2', 'agent3', 'agent4', 'agent6']),
total: 6,
@ -114,40 +166,6 @@ describe('agent_list_page', () => {
},
},
});
jest.useFakeTimers({ legacyFakeTimers: true });
});
afterEach(() => {
jest.useRealTimers();
});
it('should not send another agents status request if first one takes longer', () => {
mockedSendGetAgentStatus.mockImplementation(async () => {
const sleep = () => {
return new Promise((res) => {
setTimeout(() => res({}), 35000);
});
};
await sleep();
return {
data: {
results: {
inactive: 0,
},
},
};
});
({ utils } = renderAgentList());
act(() => {
jest.advanceTimersByTime(65000);
});
expect(mockedSendGetAgentStatus).toHaveBeenCalledTimes(1);
});
describe('selection change', () => {
beforeEach(async () => {
mockedSendGetAgentStatus.mockResolvedValue({
data: {
results: {
@ -162,18 +180,14 @@ describe('agent_list_page', () => {
expect(utils.getByText('Showing 6 agents')).toBeInTheDocument();
});
act(() => {
const selectAll = utils.container.querySelector('[data-test-subj="checkboxSelectAll"]');
fireEvent.click(selectAll!);
});
const selectAll = utils.container.querySelector('[data-test-subj="checkboxSelectAll"]');
fireEvent.click(selectAll!);
await waitFor(() => {
utils.getByText('5 agents selected');
});
act(() => {
fireEvent.click(utils.getByText('Select everything on all pages'));
});
fireEvent.click(utils.getByText('Select everything on all pages'));
utils.getByText('All agents selected');
});
@ -198,17 +212,13 @@ describe('agent_list_page', () => {
});
it('should set selection mode when agent selection changed manually', async () => {
act(() => {
fireEvent.click(utils.getAllByRole('checkbox')[3]);
});
fireEvent.click(utils.getAllByRole('checkbox')[3]);
utils.getByText('4 agents selected');
});
it('should pass sort parameters on table sort', () => {
act(() => {
fireEvent.click(utils.getByTitle('Last activity'));
});
fireEvent.click(utils.getByTitle('Last activity'));
expect(mockedSendGetAgents).toHaveBeenCalledWith(
expect.objectContaining({
@ -219,9 +229,7 @@ describe('agent_list_page', () => {
});
it('should pass keyword field on table sort on version', () => {
act(() => {
fireEvent.click(utils.getByTitle('Version'));
});
fireEvent.click(utils.getByTitle('Version'));
expect(mockedSendGetAgents).toHaveBeenCalledWith(
expect.objectContaining({
@ -232,9 +240,7 @@ describe('agent_list_page', () => {
});
it('should pass keyword field on table sort on hostname', () => {
act(() => {
fireEvent.click(utils.getByTitle('Host'));
});
fireEvent.click(utils.getByTitle('Host'));
expect(mockedSendGetAgents).toHaveBeenCalledWith(
expect.objectContaining({
@ -244,4 +250,75 @@ describe('agent_list_page', () => {
);
});
});
describe('Uninstall agent', () => {
let renderResult: RenderResult;
beforeEach(async () => {
mockedSendGetAgents.mockResolvedValue({
data: {
items: [
{
id: 'agent1',
active: true,
policy_id: 'policy1',
local_metadata: { host: { hostname: 'agent1' } },
},
{
id: 'agent2',
active: true,
policy_id: 'managed_policy',
local_metadata: { host: { hostname: 'agent2' } },
},
],
total: 2,
statusSummary: {
online: 2,
},
},
});
mockedSendGetAgentStatus.mockResolvedValue({
data: { results: { inactive: 0 }, totalInactive: 0 },
});
const renderer = createFleetTestRendererMock();
// todo: this can be removed when agentTamperProtectionEnabled feature flag is enabled/deleted
ExperimentalFeaturesService.init({
...allowedExperimentalValues,
agentTamperProtectionEnabled: true,
});
renderResult = renderer.render(<AgentListPage />);
await waitFor(() => {
expect(renderResult.queryByText('Showing 2 agents')).toBeInTheDocument();
});
});
it('should not render "Uninstall agent" menu item for managed Agent', async () => {
expect(renderResult.queryByTestId('uninstallAgentMenuItem')).not.toBeInTheDocument();
fireEvent.click(renderResult.getAllByTestId('agentActionsBtn')[1]);
expect(renderResult.queryByTestId('uninstallAgentMenuItem')).not.toBeInTheDocument();
});
it('should render "Uninstall agent" menu item for not managed Agent', async () => {
expect(renderResult.queryByTestId('uninstallAgentMenuItem')).not.toBeInTheDocument();
fireEvent.click(renderResult.getAllByTestId('agentActionsBtn')[0]);
expect(renderResult.queryByTestId('uninstallAgentMenuItem')).toBeInTheDocument();
});
it('should open uninstall commands flyout when clicking on "Uninstall agent"', () => {
fireEvent.click(renderResult.getAllByTestId('agentActionsBtn')[0]);
expect(renderResult.queryByTestId('uninstall-command-flyout')).not.toBeInTheDocument();
fireEvent.click(renderResult.getByTestId('uninstallAgentMenuItem'));
expect(renderResult.queryByTestId('uninstall-command-flyout')).toBeInTheDocument();
});
});
});

View file

@ -25,7 +25,7 @@ import {
sendGetAgentTags,
useFleetServerStandalone,
} from '../../../hooks';
import { AgentEnrollmentFlyout } from '../../../components';
import { AgentEnrollmentFlyout, UninstallCommandFlyout } from '../../../components';
import {
AgentStatusKueryHelper,
ExperimentalFeaturesService,
@ -133,6 +133,9 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
// Agent actions states
const [agentToReassign, setAgentToReassign] = useState<Agent | undefined>(undefined);
const [agentToUnenroll, setAgentToUnenroll] = useState<Agent | undefined>(undefined);
const [agentToGetUninstallCommand, setAgentToGetUninstallCommand] = useState<Agent | undefined>(
undefined
);
const [agentToUpgrade, setAgentToUpgrade] = useState<Agent | undefined>(undefined);
const [agentToAddRemoveTags, setAgentToAddRemoveTags] = useState<Agent | undefined>(undefined);
const [tagsPopoverButton, setTagsPopoverButton] = useState<HTMLElement>();
@ -207,6 +210,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
setAgentToAddRemoveTags(agent);
setShowTagsAddRemove(!showTagsAddRemove);
}}
onGetUninstallCommandClick={() => setAgentToGetUninstallCommand(agent)}
onRequestDiagnosticsClick={() => setAgentToRequestDiagnostics(agent)}
/>
);
@ -453,6 +457,18 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
/>
</EuiPortal>
)}
{agentToGetUninstallCommand && (
<EuiPortal>
<UninstallCommandFlyout
target="agent"
policyId={agentToGetUninstallCommand.policy_id}
onClose={() => {
setAgentToGetUninstallCommand(undefined);
refreshAgents({ refreshTags: true });
}}
/>
</EuiPortal>
)}
{agentToUpgrade && (
<EuiPortal>
<AgentUpgradeAgentModal

View file

@ -28,3 +28,4 @@ export { ConfirmForceInstallModal } from './confirm_force_install_modal';
export { DevtoolsRequestFlyoutButton } from './devtools_request_flyout';
export { HeaderReleaseBadge, InlineReleaseBadge } from './release_badge';
export { WithGuidedOnboardingTour } from './with_guided_onboarding_tour';
export { UninstallCommandFlyout } from './uninstall_command_flyout';

View file

@ -0,0 +1,48 @@
/*
* 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 { EuiButtonGroup, EuiCodeBlock, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useMemo } from 'react';
import type { PLATFORM_TYPE } from '../../hooks';
import { PLATFORM_OPTIONS, usePlatform } from '../../hooks';
import type { Commands } from './types';
interface Props {
commands: Commands;
}
export const CommandsForPlatforms: React.FunctionComponent<Props> = ({ commands }) => {
const { platform, setPlatform } = usePlatform();
const options = useMemo(
() => PLATFORM_OPTIONS.filter(({ id }) => commands[id as PLATFORM_TYPE]),
[commands]
);
return (
<>
<EuiButtonGroup
options={options}
idSelected={platform}
onChange={(id) => setPlatform(id as PLATFORM_TYPE)}
legend={i18n.translate('xpack.fleet.agentUninstallCommandFlyout.platformSelectAriaLabel', {
defaultMessage: 'Platform',
})}
data-test-subj="uninstall-commands-flyout-platforms-btn-group"
/>
<EuiSpacer size="s" />
<EuiCodeBlock fontSize="m" isCopyable paddingSize="m">
{commands[platform]}
</EuiCodeBlock>
</>
);
};

View file

@ -0,0 +1,34 @@
/*
* 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 { useMemo } from 'react';
import { REDUCED_PLATFORM_OPTIONS } from '../../hooks';
import type { Commands, UninstallCommandTarget } from './types';
// todo: update with real API
export const useCommands = (
policyId: string | undefined,
target: UninstallCommandTarget
): Commands => {
const commands = useMemo(
() =>
REDUCED_PLATFORM_OPTIONS.map(({ id }) => id).reduce<Commands>(
(_commands, platform) => ({
..._commands,
[platform]: policyId
? `${platform}/${target} command for ${policyId}`
: `${platform}/${target} command`,
}),
{}
),
[policyId, target]
);
return commands;
};

View file

@ -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 { UninstallCommandFlyout } from './uninstall_command_flyout';

View file

@ -0,0 +1,15 @@
/*
* 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 type { PLATFORM_TYPE } from '../../hooks';
export const UNINSTALL_COMMAND_TARGETS = ['agent', 'endpoint'] as const;
export type UninstallCommandTarget = typeof UNINSTALL_COMMAND_TARGETS[number];
export type Commands = {
[key in PLATFORM_TYPE]?: string;
};

View file

@ -0,0 +1,42 @@
/*
* 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 type { UninstallCommandFlyoutProps } from './uninstall_command_flyout';
import { UNINSTALL_COMMAND_TARGETS } from './types';
import { UninstallCommandFlyout } from './uninstall_command_flyout';
export default {
component: UninstallCommandFlyout,
title: 'Sections/Fleet/Uninstall command flyout',
argTypes: {
target: {
options: UNINSTALL_COMMAND_TARGETS,
control: { type: 'radio' },
},
},
args: {
onClose: () => {},
},
};
interface Story {
args: Partial<UninstallCommandFlyoutProps>;
}
export const ForAgent: Story = {
args: {
target: 'agent',
policyId: 'policy-id-1',
},
};
export const ForEndpoint: Story = {
args: {
target: 'endpoint',
policyId: 'policy-id-2',
},
};

View file

@ -0,0 +1,100 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { createFleetTestRendererMock } from '../../mock';
import type { UninstallCommandFlyoutProps } from './uninstall_command_flyout';
import { UninstallCommandFlyout } from './uninstall_command_flyout';
import { useCommands } from './hooks';
import type { Commands } from './types';
jest.mock('./hooks', () => ({
useCommands: jest.fn(),
}));
describe('UninstallCommandFlyout', () => {
const defaultCommands: Commands = {
linux: 'command for linux',
mac: 'command for mac',
deb: 'commands for deb',
windows: 'commands for windows',
rpm: 'commands for rpm',
};
const useCommandsMock = useCommands as jest.Mock;
const render = (props: Partial<UninstallCommandFlyoutProps> = {}) => {
const renderer = createFleetTestRendererMock();
return renderer.render(
<UninstallCommandFlyout onClose={() => {}} target="agent" policyId="-" {...props} />
);
};
beforeEach(() => {
useCommandsMock.mockReturnValue(defaultCommands);
});
describe('uninstall command targets', () => {
it('renders flyout for Agent', () => {
const renderResult = render({ target: 'agent' });
expect(renderResult.queryByText(/Uninstall Elastic Agent on your host/)).toBeInTheDocument();
expect(renderResult.queryByText(/Uninstall Elastic Defend/)).not.toBeInTheDocument();
});
it('renders flyout for Endpoint integration', () => {
const renderResult = render({ target: 'endpoint' });
expect(renderResult.queryByText(/Uninstall Elastic Defend/)).toBeInTheDocument();
expect(
renderResult.queryByText(/Uninstall Elastic Agent on your host/)
).not.toBeInTheDocument();
});
});
describe('rendering commands only for received platforms', () => {
it('renders commands for e.g. Linux and Mac', () => {
useCommandsMock.mockReturnValue({
linux: 'command for linux',
mac: 'command for mac',
});
const renderResult = render();
const platformsButtonGroup = renderResult.getByTestId(
'uninstall-commands-flyout-platforms-btn-group'
);
expect(platformsButtonGroup).toHaveTextContent('Mac');
expect(platformsButtonGroup).toHaveTextContent('Linux');
expect(platformsButtonGroup).not.toHaveTextContent('Windows');
expect(platformsButtonGroup).not.toHaveTextContent('RPM');
expect(platformsButtonGroup).not.toHaveTextContent('DEB');
});
it('renders commands for e.g. Mac, Windows, DEB and RPM', () => {
useCommandsMock.mockReturnValue({
mac: 'command for mac',
deb: 'commands for deb',
windows: 'commands for windows',
rpm: 'commands for rpm',
});
const renderResult = render();
const platformsButtonGroup = renderResult.getByTestId(
'uninstall-commands-flyout-platforms-btn-group'
);
expect(platformsButtonGroup).toHaveTextContent('Mac');
expect(platformsButtonGroup).toHaveTextContent('Windows');
expect(platformsButtonGroup).toHaveTextContent('RPM');
expect(platformsButtonGroup).toHaveTextContent('DEB');
expect(platformsButtonGroup).not.toHaveTextContent('Linux');
});
});
});

View file

@ -0,0 +1,117 @@
/*
* 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 {
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiLink,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { useStartServices } from '../../hooks';
import { CommandsForPlatforms } from './commands_for_platforms';
import { useCommands } from './hooks';
import type { UninstallCommandTarget } from './types';
const UninstallAgentDescription = () => {
const { docLinks } = useStartServices();
return (
<>
<p>
<FormattedMessage
id="xpack.fleet.agentUninstallCommandFlyout.firstParagraph"
defaultMessage="Uninstall Elastic Agent and unenroll in Fleet to stop communicating with the host."
/>
</p>
<h3>
<FormattedMessage
id="xpack.fleet.agentUninstallCommandFlyout.subtitle"
defaultMessage="Uninstall Elastic Agent on your host"
/>
</h3>
<p>
<FormattedMessage
id="xpack.fleet.agentUninstallCommandFlyout.description"
defaultMessage="Select the appropriate platform and run the command to uninstall Elastic Agent. Reuse the command to uninstall agents on more than one host. {learnMoreLink}"
values={{
learnMoreLink: (
<EuiLink href={docLinks.links.fleet.uninstallAgent} target="_blank">
{i18n.translate('xpack.fleet.agentUninstallCommandFlyout.learnMore', {
defaultMessage: 'Learn more',
})}
</EuiLink>
),
}}
/>
</p>
</>
);
};
const UninstallEndpointDescription = () => (
<>
<h3>
<FormattedMessage
id="xpack.fleet.endpointUninstallCommandFlyout.subtitle"
defaultMessage="Uninstall Elastic Defend integration on your host"
/>
</h3>
<p>
<FormattedMessage
id="xpack.fleet.endpointUninstallCommandFlyout.description"
defaultMessage="Use the below uninstall command to uninstall Endpoint integration... [TODO]"
/>
</p>
</>
);
export interface UninstallCommandFlyoutProps {
target: UninstallCommandTarget;
policyId?: string;
onClose: () => void;
}
export const UninstallCommandFlyout: React.FunctionComponent<UninstallCommandFlyoutProps> = ({
policyId,
onClose,
target,
}) => {
const commands = useCommands(policyId, target);
return (
<EuiFlyout onClose={onClose} data-test-subj="uninstall-command-flyout">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
<FormattedMessage
id="xpack.fleet.agentUninstallCommandFlyout.title"
defaultMessage="Uninstall agent"
/>
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiText>
{target === 'agent' ? <UninstallAgentDescription /> : <UninstallEndpointDescription />}
</EuiText>
<EuiSpacer size="l" />
<CommandsForPlatforms commands={commands} />
</EuiFlyoutBody>
</EuiFlyout>
);
};