diff --git a/x-pack/legacy/plugins/fleet/index.ts b/x-pack/legacy/plugins/fleet/index.ts index 0b91aadf1c5aa..4d356736ee3e5 100644 --- a/x-pack/legacy/plugins/fleet/index.ts +++ b/x-pack/legacy/plugins/fleet/index.ts @@ -33,6 +33,7 @@ export function fleet(kibana: any) { // euiIconType: 'apmApp', // order: 8000, // }, + styleSheetPaths: resolve(__dirname, 'public/index.scss'), managementSections: ['plugins/fleet'], savedObjectSchemas: { agents: { diff --git a/x-pack/legacy/plugins/fleet/public/components/agent_unenroll_provider.tsx b/x-pack/legacy/plugins/fleet/public/components/agent_unenroll_provider.tsx new file mode 100644 index 0000000000000..256ee1eef893f --- /dev/null +++ b/x-pack/legacy/plugins/fleet/public/components/agent_unenroll_provider.tsx @@ -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 = ({ children }) => { + const libs = useLibs(); + const [agents, setAgents] = useState([]); + const [agentsCount, setAgentsCount] = useState(0); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const onSuccessCallback = useRef(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 ( + + + ) : ( + + ) + } + onCancel={closeModal} + onConfirm={unenrollAgents} + cancelButtonText={ + + } + confirmButtonText={ + isLoading ? ( + + ) : ( + + ) + } + buttonColor="danger" + confirmButtonDisabled={isLoading} + /> + + ); + }; + + return ( + + {children(unenrollAgentsPrompt)} + {renderModal()} + + ); +}; diff --git a/x-pack/legacy/plugins/fleet/public/index.scss b/x-pack/legacy/plugins/fleet/public/index.scss new file mode 100644 index 0000000000000..eab372c3707c5 --- /dev/null +++ b/x-pack/legacy/plugins/fleet/public/index.scss @@ -0,0 +1 @@ +@import 'pages/agent_list/index.scss'; diff --git a/x-pack/legacy/plugins/fleet/public/lib/adapters/framework/adapter_types.ts b/x-pack/legacy/plugins/fleet/public/lib/adapters/framework/adapter_types.ts index a9fc9be2aaa97..750bae8b4792b 100644 --- a/x-pack/legacy/plugins/fleet/public/lib/adapters/framework/adapter_types.ts +++ b/x-pack/legacy/plugins/fleet/public/lib/adapters/framework/adapter_types.ts @@ -15,6 +15,7 @@ export interface FrameworkAdapter { version: string; capabilities: { read: boolean; write: boolean }; currentUser: FrameworkUser; + notifications: any; // Methods waitUntilFrameworkReady(): Promise; renderUIAtPath( diff --git a/x-pack/legacy/plugins/fleet/public/lib/adapters/framework/kibana_framework_adapter.ts b/x-pack/legacy/plugins/fleet/public/lib/adapters/framework/kibana_framework_adapter.ts index 9088d503a888f..ecded5335ab66 100644 --- a/x-pack/legacy/plugins/fleet/public/lib/adapters/framework/kibana_framework_adapter.ts +++ b/x-pack/legacy/plugins/fleet/public/lib/adapters/framework/kibana_framework_adapter.ts @@ -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, @@ -51,7 +52,8 @@ export class KibanaFrameworkAdapter implements FrameworkAdapter { private readonly getBasePath: () => string, private readonly onKibanaReady: () => Promise, private readonly XPackInfoProvider: unknown, - public readonly version: string + public readonly version: string, + public readonly notifications: typeof toastNotifications ) { this.adapterService = new KibanaAdapterServiceProvider(); } diff --git a/x-pack/legacy/plugins/fleet/public/lib/adapters/framework/testing_framework_adapter.ts b/x-pack/legacy/plugins/fleet/public/lib/adapters/framework/testing_framework_adapter.ts index 6c94efdbce5cd..5b79b6c32ddc5 100644 --- a/x-pack/legacy/plugins/fleet/public/lib/adapters/framework/testing_framework_adapter.ts +++ b/x-pack/legacy/plugins/fleet/public/lib/adapters/framework/testing_framework_adapter.ts @@ -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 { diff --git a/x-pack/legacy/plugins/fleet/public/lib/agent.ts b/x-pack/legacy/plugins/fleet/public/lib/agent.ts index 5a150f78c23f2..46d0a6a7ef838 100644 --- a/x-pack/legacy/plugins/fleet/public/lib/agent.ts +++ b/x-pack/legacy/plugins/fleet/public/lib/agent.ts @@ -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); + }; } diff --git a/x-pack/legacy/plugins/fleet/public/lib/compose/kibana.ts b/x-pack/legacy/plugins/fleet/public/lib/compose/kibana.ts index f38760934a5ff..f9c2c137eca8b 100644 --- a/x-pack/legacy/plugins/fleet/public/lib/compose/kibana.ts +++ b/x-pack/legacy/plugins/fleet/public/lib/compose/kibana.ts @@ -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'; @@ -40,7 +41,8 @@ export function compose(): FrontendLibs { chrome.getBasePath, onKibanaReady, XPackInfoProvider, - chrome.getKibanaVersion() + chrome.getKibanaVersion(), + toastNotifications ) ); diff --git a/x-pack/legacy/plugins/fleet/public/lib/compose/memory.ts b/x-pack/legacy/plugins/fleet/public/lib/compose/memory.ts index 2ebc10d83f830..008c8a285c91f 100644 --- a/x-pack/legacy/plugins/fleet/public/lib/compose/memory.ts +++ b/x-pack/legacy/plugins/fleet/public/lib/compose/memory.ts @@ -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'; @@ -47,7 +49,8 @@ export function compose( () => '', onKibanaReady, null, - '7.0.0' + '7.0.0', + toastNotifications ) ); const libs: FrontendLibs = { diff --git a/x-pack/legacy/plugins/fleet/public/lib/framework.ts b/x-pack/legacy/plugins/fleet/public/lib/framework.ts index f6b9ec46d0a2a..9cbd9100c9e30 100644 --- a/x-pack/legacy/plugins/fleet/public/lib/framework.ts +++ b/x-pack/legacy/plugins/fleet/public/lib/framework.ts @@ -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) diff --git a/x-pack/legacy/plugins/fleet/public/pages/agent_details/components/details_section.tsx b/x-pack/legacy/plugins/fleet/public/pages/agent_details/components/details_section.tsx index dea3a8397cf16..83f9fbb5790ac 100644 --- a/x-pack/legacy/plugins/fleet/public/pages/agent_details/components/details_section.tsx +++ b/x-pack/legacy/plugins/fleet/public/pages/agent_details/components/details_section.tsx @@ -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 ( @@ -45,11 +47,11 @@ function useFlyout() { interface Props { agent: Agent; - unenrollment: { loading: boolean }; - onClickUnenroll: () => void; } -export const AgentDetailSection: SFC = ({ agent, onClickUnenroll, unenrollment }) => { +export const AgentDetailSection: SFC = ({ agent }) => { const metadataFlyout = useFlyout(); + const refreshAgent = useAgentRefresh(); + const items = [ { title: i18n.translate('xpack.fleet.agentDetails.statusLabel', { @@ -99,16 +101,21 @@ export const AgentDetailSection: SFC = ({ agent, onClickUnenroll, unenrol - - - + + {unenrollAgentsPrompt => ( + { + unenrollAgentsPrompt([agent.id], 1, refreshAgent); + }} + > + + + )} + diff --git a/x-pack/legacy/plugins/fleet/public/pages/agent_details/components/modal_confirm_unenroll.tsx b/x-pack/legacy/plugins/fleet/public/pages/agent_details/components/modal_confirm_unenroll.tsx deleted file mode 100644 index 3927c2684fc97..0000000000000 --- a/x-pack/legacy/plugins/fleet/public/pages/agent_details/components/modal_confirm_unenroll.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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, { SFC } from 'react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -interface Props { - onConfirm: () => void; - onCancel: () => void; -} - -export const ModalConfirmUnenroll: SFC = ({ onConfirm, onCancel }) => { - return ( - - - - ); -}; diff --git a/x-pack/legacy/plugins/fleet/public/pages/agent_details/hooks/use_unenroll.tsx b/x-pack/legacy/plugins/fleet/public/pages/agent_details/hooks/use_unenroll.tsx deleted file mode 100644 index ea708dd674384..0000000000000 --- a/x-pack/legacy/plugins/fleet/public/pages/agent_details/hooks/use_unenroll.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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 { useState } from 'react'; -import { useLibs } from '../../../hooks/use_libs'; - -export function useUnenroll(refreshAgent: () => Promise, agentId: string) { - const { agents } = useLibs(); - const [state, setState] = useState< - | { - confirm: false; - loading: false; - } - | { - confirm: true; - loading: false; - } - | { - confirm: false; - loading: true; - } - >({ - confirm: false, - loading: false, - }); - - return { - state, - showConfirmModal: () => - setState({ - confirm: true, - loading: false, - }), - confirmUnenrollement: async () => { - setState({ - confirm: false, - loading: true, - }); - - await agents.unenroll([agentId]); - - setState({ - confirm: false, - loading: false, - }); - refreshAgent(); - }, - clear: () => { - setState({ - confirm: false, - loading: false, - }); - }, - }; -} diff --git a/x-pack/legacy/plugins/fleet/public/pages/agent_details/index.tsx b/x-pack/legacy/plugins/fleet/public/pages/agent_details/index.tsx index 981a7189bba3c..f3f89cea0accf 100644 --- a/x-pack/legacy/plugins/fleet/public/pages/agent_details/index.tsx +++ b/x-pack/legacy/plugins/fleet/public/pages/agent_details/index.tsx @@ -17,11 +17,9 @@ import { EuiSpacer, } from '@elastic/eui'; import { RouteComponentProps } from 'react-router-dom'; -import { AgentEventsTable } from './components/agent_events_table'; import { Loading } from '../../components/loading'; +import { AgentEventsTable } from './components/agent_events_table'; import { AgentDetailSection } from './components/details_section'; -import { ModalConfirmUnenroll } from './components/modal_confirm_unenroll'; -import { useUnenroll } from './hooks/use_unenroll'; import { useGetAgent, AgentRefreshContext } from './hooks/use_agent'; export const Layout: SFC = ({ children }) => ( @@ -40,7 +38,6 @@ export const AgentDetailsPage: SFC = ({ }, }) => { const { agent, isLoading, error, refreshAgent } = useGetAgent(agentId); - const unenroll = useUnenroll(refreshAgent, agentId); if (isLoading) { return ; @@ -78,17 +75,7 @@ export const AgentDetailsPage: SFC = ({ return ( - {unenroll.state.confirm && ( - - )} - + diff --git a/x-pack/legacy/plugins/fleet/public/pages/agent_list/index.scss b/x-pack/legacy/plugins/fleet/public/pages/agent_list/index.scss new file mode 100644 index 0000000000000..10e809c5f5566 --- /dev/null +++ b/x-pack/legacy/plugins/fleet/public/pages/agent_list/index.scss @@ -0,0 +1,6 @@ +.fleet__agentList__table .euiTableFooterCell { + .euiTableCellContent, + .euiTableCellContent__text { + overflow: visible; + } +} \ No newline at end of file diff --git a/x-pack/legacy/plugins/fleet/public/pages/agent_list/index.tsx b/x-pack/legacy/plugins/fleet/public/pages/agent_list/index.tsx index 4fbb84917f89f..60669b0600a09 100644 --- a/x-pack/legacy/plugins/fleet/public/pages/agent_list/index.tsx +++ b/x-pack/legacy/plugins/fleet/public/pages/agent_list/index.tsx @@ -18,6 +18,7 @@ import { EuiEmptyPrompt, // @ts-ignore EuiSearchBar, + EuiLink, EuiSwitch, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -25,6 +26,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { AGENT_POLLING_THRESHOLD_MS } from '../../../common/constants'; import { Agent } from '../../../common/types/domain_data'; import { AgentHealth } from '../../components/agent_health'; +import { AgentUnenrollProvider } from '../../components/agent_unenroll_provider'; import { ConnectedLink } from '../../components/navigation/connected_link'; import { usePagination } from '../../hooks/use_pagination'; import { SearchBar } from '../../components/search_bar'; @@ -43,6 +45,8 @@ export const AgentListPage: React.SFC<{}> = () => { // Table and search states const [search, setSearch] = useState(''); const { pagination, pageSizeOptions, setPagination } = usePagination(); + const [selectedAgents, setSelectedAgents] = useState([]); + const [areAllAgentsSelected, setAreAllAgentsSelected] = useState(false); // Agent enrollment flyout state const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState(false); @@ -70,6 +74,7 @@ export const AgentListPage: React.SFC<{}> = () => { // Update agents if pagination or query state changes useEffect(() => { fetchAgents(); + setAreAllAgentsSelected(false); }, [pagination, search]); // Poll for agents on interval @@ -86,17 +91,48 @@ export const AgentListPage: React.SFC<{}> = () => { name: i18n.translate('xpack.fleet.agentList.hostColumnTitle', { defaultMessage: 'Host', }), - truncateText: true, + footer: () => { + if (selectedAgents.length === agents.length && totalAgents > selectedAgents.length) { + return areAllAgentsSelected ? ( + setAreAllAgentsSelected(false)}> + + + ), + }} + /> + ) : ( + setAreAllAgentsSelected(true)}> + + + ), + }} + /> + ); + } + return null; + }, }, - // { - // field: 'id', - // name: i18n.translate('xpack.fleet.agentList.metaColumnTitle', { - // defaultMessage: 'Meta', - // }), - // truncateText: true, - // sortable: true, - // render: () => some-region, - // }, { field: 'policy_id', name: i18n.translate('xpack.fleet.agentList.policyColumnTitle', { @@ -104,15 +140,6 @@ export const AgentListPage: React.SFC<{}> = () => { }), truncateText: true, }, - // { - // field: 'event_rate', - // name: i18n.translate('xpack.fleet.agentList.eventsColumnTitle', { - // defaultMessage: 'Events (24h)', - // }), - // truncateText: true, - // sortable: true, - // render: () => 34, - // }, { field: 'active', name: i18n.translate('xpack.fleet.agentList.statusColumnTitle', { @@ -201,9 +228,61 @@ export const AgentListPage: React.SFC<{}> = () => { + + {selectedAgents.length ? ( + + + {unenrollAgentsPrompt => ( + { + unenrollAgentsPrompt( + areAllAgentsSelected ? search : selectedAgents.map(agent => agent.id), + areAllAgentsSelected ? totalAgents : selectedAgents.length, + () => { + // Reload agents if on first page and no search query, otherwise + // reset to first page and reset search, which will trigger a reload + if (pagination.currentPage === 1 && !search) { + fetchAgents(); + } else { + setPagination({ + ...pagination, + currentPage: 1, + }); + setSearch(''); + } + + setAreAllAgentsSelected(false); + setSelectedAgents([]); + } + ); + }} + > + + + )} + + + ) : null} - + { + setPagination({ + ...pagination, + currentPage: 1, + }); + setSearch(newSearch); + }} + fieldPrefix="agents" + /> {libs.framework.capabilities.write && ( @@ -223,13 +302,14 @@ export const AgentListPage: React.SFC<{}> = () => { = () => { items={totalAgents ? agents : []} itemId="id" columns={columns} + isSelectable={true} + selection={{ + selectable: (agent: Agent) => agent.active, + onSelectionChange: (newSelectedAgents: Agent[]) => { + setSelectedAgents(newSelectedAgents); + setAreAllAgentsSelected(false); + }, + }} pagination={{ pageIndex: pagination.currentPage - 1, pageSize: pagination.pageSize,