Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multiselect to unenroll agents #48852

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions x-pack/legacy/plugins/fleet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function fleet(kibana: any) {
// euiIconType: 'apmApp',
// order: 8000,
// },
styleSheetPaths: resolve(__dirname, 'public/index.scss'),
managementSections: ['plugins/fleet'],
savedObjectSchemas: {
agents: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import React, { Fragment, useRef, useState } from 'react';
import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { useLibs } from '../hooks/use_libs';

interface Props {
children: (unenrollAgents: UnenrollAgents) => React.ReactElement;
}

export type UnenrollAgents = (
agents: string[] | string,
agentsCount: number,
onSuccess?: OnSuccessCallback
) => void;

type OnSuccessCallback = (agentsUnenrolled: string[]) => void;

export const AgentUnenrollProvider: React.FunctionComponent<Props> = ({ children }) => {
const libs = useLibs();
const [agents, setAgents] = useState<string[] | string>([]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

happy to discuss that, but I have a strong preference to use only one state { agents, agentCount, isLoading ...}:

  • With typescript you can define all the possible variant of your state and avoid illegal state for example {isLoading: true, isModalOpen: false}
  • You avoid too many setState and component re render

Also personal preference I like to move the state logic in a hook outside the component, I think it make thing more readable

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 I norm write my code like @jen-huang did in this file, but you make REALLY good points here. I am a fan. Moving state logic out would make it much easier to test the logic in unit tests so I am especially fond of that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the typescript variants and unit testing are great benefits; I'm not worried about re-rendering since 1) React batches multiple setState calls and 2) re-rendering first happens in virtual DOM before being pushed up to real DOM

I'll try moving state logic out the next chance I have and see if I like it 😄

const [agentsCount, setAgentsCount] = useState<number>(0);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const onSuccessCallback = useRef<OnSuccessCallback | null>(null);

const unenrollAgentsPrompt: UnenrollAgents = (
agentsToUnenroll,
agentsToUnenrollCount,
onSuccess = () => undefined
) => {
if (
agentsToUnenroll === undefined ||
(Array.isArray(agentsToUnenroll) && agentsToUnenroll.length === 0)
) {
throw new Error('No agents specified for unenrollment');
}
setIsModalOpen(true);
setAgents(agentsToUnenroll);
setAgentsCount(agentsToUnenrollCount);
onSuccessCallback.current = onSuccess;
};

const closeModal = () => {
setAgents([]);
setAgentsCount(0);
setIsLoading(false);
setIsModalOpen(false);
};

const unenrollAgents = async () => {
setIsLoading(true);

try {
const unenrollByKuery = typeof agents === 'string';
const agentsToUnenroll =
unenrollByKuery && !(agents as string).trim() ? 'agents.active:true' : agents;
const unenrollMethod = unenrollByKuery ? libs.agents.unenrollByKuery : libs.agents.unenroll;
const { results } = await unenrollMethod(agentsToUnenroll as string & string[]);

const successfulResults = results.filter(result => result.success);
const failedResults = results.filter(result => !result.success);

if (successfulResults.length) {
const hasMultipleSuccesses = successfulResults.length > 1;
const successMessage = hasMultipleSuccesses
? i18n.translate('xpack.fleet.unenrollAgents.successMultipleNotificationTitle', {
defaultMessage: 'Unenrolled {count} agents',
values: { count: successfulResults.length },
})
: i18n.translate('xpack.fleet.unenrollAgents.successSingleNotificationTitle', {
defaultMessage: "Unenrolled agent '{id}'",
values: { id: successfulResults[0].id },
});
libs.framework.notifications.addSuccess(successMessage);
}

if (failedResults.length) {
const hasMultipleFailures = failedResults.length > 1;
const failureMessage = hasMultipleFailures
? i18n.translate('xpack.fleet.unenrollAgents.failureMultipleNotificationTitle', {
defaultMessage: 'Error unenrolling {count} agents',
values: { count: failedResults.length },
})
: i18n.translate('xpack.fleet.unenrollAgents.failureSingleNotificationTitle', {
defaultMessage: "Error unenrolling agent '{id}'",
values: { id: failedResults[0].id },
});
libs.framework.notifications.addDanger(failureMessage);
}

if (onSuccessCallback.current) {
onSuccessCallback.current(successfulResults.map(result => result.id));
}
} catch (e) {
libs.framework.notifications.addDanger(
i18n.translate('xpack.fleet.unenrollAgents.fatalErrorNotificationTitle', {
defaultMessage: 'Fatal error unenrolling agents',
})
);
}

closeModal();
};

const renderModal = () => {
if (!isModalOpen) {
return null;
}

const unenrollByKuery = typeof agents === 'string';
const isSingle = agentsCount === 1;

return (
<EuiOverlayMask>
<EuiConfirmModal
title={
isSingle && !unenrollByKuery ? (
<FormattedMessage
id="xpack.fleet.unenrollAgents.confirmModal.deleteSingleTitle"
defaultMessage="Unenroll agent '{id}'?"
values={{ id: agents[0] }}
/>
) : (
<FormattedMessage
id="xpack.fleet.unenrollAgents.confirmModal.deleteMultipleTitle"
defaultMessage="Unenroll {count, plural, one {# agent} other {# agents}}?"
values={{ count: agentsCount }}
/>
)
}
onCancel={closeModal}
onConfirm={unenrollAgents}
cancelButtonText={
<FormattedMessage
id="xpack.fleet.unenrollAgents.confirmModal.cancelButtonLabel"
defaultMessage="Cancel"
/>
}
confirmButtonText={
isLoading ? (
<FormattedMessage
id="xpack.fleet.unenrollAgents.confirmModal.loadingButtonLabel"
defaultMessage="Loading…"
/>
) : (
<FormattedMessage
id="xpack.fleet.unenrollAgents.confirmModal.confirmButtonLabel"
defaultMessage="Unenroll"
/>
)
}
buttonColor="danger"
confirmButtonDisabled={isLoading}
/>
</EuiOverlayMask>
);
};

return (
<Fragment>
{children(unenrollAgentsPrompt)}
{renderModal()}
</Fragment>
);
};
1 change: 1 addition & 0 deletions x-pack/legacy/plugins/fleet/public/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import 'pages/agent_list/index.scss';
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface FrameworkAdapter {
version: string;
capabilities: { read: boolean; write: boolean };
currentUser: FrameworkUser;
notifications: any;
// Methods
waitUntilFrameworkReady(): Promise<void>;
renderUIAtPath(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { UIRoutes } from 'ui/routes';
import { capabilities } from 'ui/capabilities';
import { toastNotifications } from 'ui/notify';
import { BufferedKibanaServiceCall, KibanaAdapterServiceRefs, KibanaUIConfig } from '../../types';
import {
FrameworkAdapter,
Expand Down Expand Up @@ -51,7 +52,8 @@ export class KibanaFrameworkAdapter implements FrameworkAdapter {
private readonly getBasePath: () => string,
private readonly onKibanaReady: () => Promise<IInjector>,
private readonly XPackInfoProvider: unknown,
public readonly version: string
public readonly version: string,
public readonly notifications: typeof toastNotifications
) {
this.adapterService = new KibanaAdapterServiceProvider();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ export class TestingFrameworkAdapter implements FrameworkAdapter {
public readonly version: string
) {}

public get notifications(): any {
return {
addSuccess: () => {},
addDanger: () => {},
};
}

// We dont really want to have this, but it's needed to conditionaly render for k7 due to
// when that data is needed.
public getUISetting(key: 'k7design'): boolean {
Expand Down
4 changes: 4 additions & 0 deletions x-pack/legacy/plugins/fleet/public/lib/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,8 @@ export class AgentsLib {
public unenroll = async (ids: string[]) => {
return await this.adapter.unenrollByIds(ids);
};

public unenrollByKuery = async (kuery: string = '') => {
return await this.adapter.unenrollByKuery(kuery);
};
}
4 changes: 3 additions & 1 deletion x-pack/legacy/plugins/fleet/public/lib/compose/kibana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'ui/autoload/all';
import chrome from 'ui/chrome';
// @ts-ignore not typed yet
import { management } from 'ui/management';
import { toastNotifications } from 'ui/notify';
import routes from 'ui/routes';
import { RestAgentAdapter } from '../adapters/agent/rest_agent_adapter';
import { RestElasticsearchAdapter } from '../adapters/elasticsearch/rest';
Expand Down Expand Up @@ -40,7 +41,8 @@ export function compose(): FrontendLibs {
chrome.getBasePath,
onKibanaReady,
XPackInfoProvider,
chrome.getKibanaVersion()
chrome.getKibanaVersion(),
toastNotifications
)
);

Expand Down
5 changes: 4 additions & 1 deletion x-pack/legacy/plugins/fleet/public/lib/compose/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import 'ui/autoload/all';
// @ts-ignore: path dynamic for kibana
import { management } from 'ui/management';
// @ts-ignore: path dynamic for kibana
import { toastNotifications } from 'ui/notify';
// @ts-ignore: path dynamic for kibana
import { uiModules } from 'ui/modules';
// @ts-ignore: path dynamic for kibana
import routes from 'ui/routes';
Expand Down Expand Up @@ -47,7 +49,8 @@ export function compose(
() => '',
onKibanaReady,
null,
'7.0.0'
'7.0.0',
toastNotifications
)
);
const libs: FrontendLibs = {
Expand Down
4 changes: 4 additions & 0 deletions x-pack/legacy/plugins/fleet/public/lib/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export class FrameworkLib {
return this.adapter.info;
}

public get notifications() {
return this.adapter.notifications;
}

public licenseIsAtLeast(type: LicenseType) {
return (
LICENSES.indexOf(get(this.adapter.info, 'license.type', 'oss')) >= LICENSES.indexOf(type)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import {
import { i18n } from '@kbn/i18n';
import { Agent } from '../../../../common/types/domain_data';
import { AgentHealth } from '../../../components/agent_health';
import { AgentUnenrollProvider } from '../../../components/agent_unenroll_provider';
import { AgentMetadataFlyout } from './metadata_flyout';
import { useAgentRefresh } from '../hooks/use_agent';

const Item: SFC<{ label: string }> = ({ label, children }) => {
return (
Expand All @@ -45,11 +47,11 @@ function useFlyout() {

interface Props {
agent: Agent;
unenrollment: { loading: boolean };
onClickUnenroll: () => void;
}
export const AgentDetailSection: SFC<Props> = ({ agent, onClickUnenroll, unenrollment }) => {
export const AgentDetailSection: SFC<Props> = ({ agent }) => {
const metadataFlyout = useFlyout();
const refreshAgent = useAgentRefresh();

const items = [
{
title: i18n.translate('xpack.fleet.agentDetails.statusLabel', {
Expand Down Expand Up @@ -99,16 +101,21 @@ export const AgentDetailSection: SFC<Props> = ({ agent, onClickUnenroll, unenrol
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
disabled={unenrollment.loading === true || agent.active === false}
isLoading={unenrollment.loading}
onClick={onClickUnenroll}
>
<FormattedMessage
id="xpack.fleet.agentDetails.unenrollButtonText"
defaultMessage="Unenroll"
/>
</EuiButton>
<AgentUnenrollProvider>
{unenrollAgentsPrompt => (
<EuiButton
disabled={!agent.active}
onClick={() => {
unenrollAgentsPrompt([agent.id], 1, refreshAgent);
}}
>
<FormattedMessage
id="xpack.fleet.agentDetails.unenrollButtonText"
defaultMessage="Unenroll"
/>
</EuiButton>
)}
</AgentUnenrollProvider>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size={'xl'} />
Expand Down

This file was deleted.

Loading