diff --git a/x-pack/plugins/fleet/common/experimental_features.ts b/x-pack/plugins/fleet/common/experimental_features.ts index bc31bfc965505..1c42bf3ab2afe 100644 --- a/x-pack/plugins/fleet/common/experimental_features.ts +++ b/x-pack/plugins/fleet/common/experimental_features.ts @@ -27,7 +27,7 @@ const _allowedExperimentalValues = { useSpaceAwareness: false, enableReusableIntegrationPolicies: true, asyncDeployPolicies: true, - enableExportCSV: false, + enableExportCSV: true, }; /** diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx index ea020417a2a2b..4c207da0ee541 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx @@ -33,6 +33,8 @@ import { AgentRequestDiagnosticsModal } from '../../components/agent_request_dia import { useExportCSV } from '../hooks/export_csv'; +import { AgentExportCSVModal } from '../../components/agent_export_csv_modal'; + import type { SelectionMode } from './types'; import { TagsAddRemove } from './tags_add_remove'; @@ -82,6 +84,7 @@ export const AgentBulkActions: React.FunctionComponent = ({ const [isTagAddVisible, setIsTagAddVisible] = useState(false); const [isRequestDiagnosticsModalOpen, setIsRequestDiagnosticsModalOpen] = useState(false); + const [isExportCSVModalOpen, setIsExportCSVModalOpen] = useState(false); // update the query removing the "managed" agents in any state (unenrolled, offline, etc) const selectionQuery = useMemo(() => { @@ -241,10 +244,7 @@ export const AgentBulkActions: React.FunctionComponent = ({ icon: , onClick: () => { closeMenu(); - generateReportingJobCSV(agents, { - field: sortField, - direction: sortOrder, - }); + setIsExportCSVModalOpen(true); }, }, ] @@ -288,6 +288,23 @@ export const AgentBulkActions: React.FunctionComponent = ({ /> )} + {isExportCSVModalOpen && ( + + ) => { + generateReportingJobCSV(agents, columns, { + field: sortField, + direction: sortOrder, + }); + setIsExportCSVModalOpen(false); + }} + onClose={() => { + setIsExportCSVModalOpen(false); + }} + agentCount={agentCount} + /> + + )} {upgradeModalState.isOpen && ( { field: 'agent.id', direction: 'asc', }; + const columns = [{ field: 'agent.id' }]; act(() => { - result.result.current.generateReportingJobCSV(agents, sortOptions); + result.result.current.generateReportingJobCSV(agents, columns, sortOptions); }); - expect(mockGetDecoratedJobParams.mock.calls[0][0].columns.length).toEqual(6); + expect(mockGetDecoratedJobParams.mock.calls[0][0].columns.length).toEqual(1); expect(mockGetDecoratedJobParams.mock.calls[0][0].searchSource).toEqual( expect.objectContaining({ filter: expect.objectContaining({ @@ -127,12 +128,13 @@ describe('export_csv', () => { it('should generate reporting job for export csv with agents query', () => { const agents = 'policy_id:1 AND status:online'; + const columns = [{ field: 'agent.id' }]; act(() => { - result.result.current.generateReportingJobCSV(agents, undefined); + result.result.current.generateReportingJobCSV(agents, columns, undefined); }); - expect(mockGetDecoratedJobParams.mock.calls[0][0].columns.length).toEqual(6); + expect(mockGetDecoratedJobParams.mock.calls[0][0].columns.length).toEqual(1); expect(mockGetDecoratedJobParams.mock.calls[0][0].searchSource).toEqual( expect.objectContaining({ filter: expect.objectContaining({ diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/export_csv.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/export_csv.tsx index e7a806ff11f02..7fb19367b7d47 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/export_csv.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/export_csv.tsx @@ -23,6 +23,8 @@ import { import type { Agent } from '../../../../../../../common'; import { getSortConfig, removeSOAttributes } from '../../../../../../../common'; +import type { ExportField } from '../../components/agent_export_csv_modal'; + import { getSortFieldForAPI } from './use_fetch_agents_data'; export function useExportCSV(enableExportCSV?: boolean) { @@ -36,19 +38,9 @@ export function useExportCSV(enableExportCSV?: boolean) { const getJobParams = ( agents: Agent[] | string, + columns: Array<{ field: string }>, sortOptions?: { field?: string; direction?: string } ) => { - // TODO pass columns from Agent list UI - // TODO set readable column names - const columns = [ - { field: 'agent.id' }, - { field: 'status' }, - { field: 'local_metadata.host.hostname' }, - { field: 'policy_id' }, // policy name would need to be enriched - { field: 'last_checkin' }, - { field: 'local_metadata.elastic.agent.version' }, - ]; - const index = new DataView({ spec: { title: '.fleet-agents', @@ -108,9 +100,12 @@ export function useExportCSV(enableExportCSV?: boolean) { // copied and adapted logic from here: https://github.com/elastic/kibana/blob/2846a162de7e56d2107eeb2e33e006a3310a4ae1/packages/kbn-reporting/public/share/share_context_menu/register_csv_modal_reporting.tsx#L86 const generateReportingJobCSV = ( agents: Agent[] | string, + columns: ExportField[], sortOptions?: { field?: string; direction?: string } ) => { - const decoratedJobParams = apiClient.getDecoratedJobParams(getJobParams(agents, sortOptions)); + const decoratedJobParams = apiClient.getDecoratedJobParams( + getJobParams(agents, columns, sortOptions) + ); return apiClient .createReportingShareJob('csv_searchsource', decoratedJobParams) .then(() => { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_export_csv_modal/columns.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_export_csv_modal/columns.ts new file mode 100644 index 0000000000000..cc39dc594d5ff --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_export_csv_modal/columns.ts @@ -0,0 +1,234 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const INITIAL_AGENT_FIELDS_TO_EXPORT = [ + { + field: 'agent.id', + description: i18n.translate('xpack.fleet.exportCSV.agentIdField', { + defaultMessage: 'Agent ID', + }), + }, + { + field: 'status', + description: i18n.translate('xpack.fleet.exportCSV.statusField', { defaultMessage: 'Status' }), + }, + { + field: 'local_metadata.host.hostname', + description: i18n.translate('xpack.fleet.exportCSV.hostnameField', { + defaultMessage: 'Host Name', + }), + }, + { + field: 'policy_id', + description: i18n.translate('xpack.fleet.exportCSV.policyIdField', { + defaultMessage: 'Policy ID', + }), + }, // policy name would need to be enriched + { + field: 'last_checkin', + description: i18n.translate('xpack.fleet.exportCSV.lastCheckinField', { + defaultMessage: 'Last Checkin Time', + }), + }, + { + field: 'local_metadata.elastic.agent.version', + description: i18n.translate('xpack.fleet.exportCSV.agentVersionField', { + defaultMessage: 'Agent Version', + }), + }, +]; + +export const AGENT_FIELDS_TO_EXPORT = [ + { + field: 'active', + description: i18n.translate('xpack.fleet.exportCSV.activeField', { defaultMessage: 'Active' }), + }, + { + field: 'audit_unenrolled_reason', + description: i18n.translate('xpack.fleet.exportCSV.auditUnenrolledReasonField', { + defaultMessage: 'Audit Unenrolled Reason', + }), + }, + { + field: 'audit_unenrolled_time', + description: i18n.translate('xpack.fleet.exportCSV.auditUnenrolledTimeField', { + defaultMessage: 'Audit Unenrolled Time', + }), + }, + { + field: 'enrolled_at', + description: i18n.translate('xpack.fleet.exportCSV.enrolledAtField', { + defaultMessage: 'Enrolled At', + }), + }, + { + field: 'last_checkin_message', + description: i18n.translate('xpack.fleet.exportCSV.lastCheckinMessageField', { + defaultMessage: 'Last Checkin Message', + }), + }, + { + field: 'last_checkin_status', + description: i18n.translate('xpack.fleet.exportCSV.lastCheckinStatusField', { + defaultMessage: 'Last Checkin Status', + }), + }, + { + field: 'last_updated', + description: i18n.translate('xpack.fleet.exportCSV.lastUpdatedField', { + defaultMessage: 'Last Updated Time', + }), + }, + { + field: 'local_metadata.elastic.agent.build.original', + description: i18n.translate('xpack.fleet.exportCSV.agentBuildOriginalField', { + defaultMessage: 'Agent Build Original', + }), + }, + { + field: 'local_metadata.elastic.agent.log_level', + description: i18n.translate('xpack.fleet.exportCSV.logLevelField', { + defaultMessage: 'Agent Log Level', + }), + }, + { + field: 'local_metadata.elastic.agent.snapshot', + description: i18n.translate('xpack.fleet.exportCSV.agentSnapshotField', { + defaultMessage: 'Agent Snapshot', + }), + }, + { + field: 'local_metadata.elastic.agent.unprivileged', + description: i18n.translate('xpack.fleet.exportCSV.agentUnprivilegedField', { + defaultMessage: 'Agent Unprivileged', + }), + }, + { + field: 'local_metadata.elastic.agent.upgradeable', + description: i18n.translate('xpack.fleet.exportCSV.agentUpgradeableField', { + defaultMessage: 'Agent Upgradeable', + }), + }, + { + field: 'local_metadata.host.architecture', + description: i18n.translate('xpack.fleet.exportCSV.hostArchitectureField', { + defaultMessage: 'Host Architecture', + }), + }, + { + field: 'local_metadata.host.id', + description: i18n.translate('xpack.fleet.exportCSV.hostIdField', { defaultMessage: 'Host ID' }), + }, + { + field: 'local_metadata.host.ip', + description: i18n.translate('xpack.fleet.exportCSV.hostIpField', { defaultMessage: 'Host IP' }), + }, + { + field: 'local_metadata.host.mac', + description: i18n.translate('xpack.fleet.exportCSV.hostMacField', { + defaultMessage: 'Host Mac', + }), + }, + { + field: 'local_metadata.host.name', + description: i18n.translate('xpack.fleet.exportCSV.hostNameField', { + defaultMessage: 'Host Name', + }), + }, + { + field: 'local_metadata.os.family', + description: i18n.translate('xpack.fleet.exportCSV.osFamilyField', { + defaultMessage: 'OS Family', + }), + }, + { + field: 'local_metadata.os.full', + description: i18n.translate('xpack.fleet.exportCSV.osFullField', { defaultMessage: 'OS Full' }), + }, + { + field: 'local_metadata.os.kernel', + description: i18n.translate('xpack.fleet.exportCSV.osKernelField', { + defaultMessage: 'OS Kernel', + }), + }, + { + field: 'local_metadata.os.name', + description: i18n.translate('xpack.fleet.exportCSV.osNameField', { defaultMessage: 'OS Name' }), + }, + { + field: 'local_metadata.os.platform', + description: i18n.translate('xpack.fleet.exportCSV.osPlatformField', { + defaultMessage: 'OS Platform', + }), + }, + { + field: 'local_metadata.os.version', + description: i18n.translate('xpack.fleet.exportCSV.osVersionField', { + defaultMessage: 'OS Version', + }), + }, + { + field: 'tags', + description: i18n.translate('xpack.fleet.exportCSV.tagsField', { defaultMessage: 'Tags' }), + }, + { + field: 'unenrolled_at', + description: i18n.translate('xpack.fleet.exportCSV.unenrolledAtField', { + defaultMessage: 'Unenrolled At', + }), + }, + { + field: 'unenrolled_reason', + description: i18n.translate('xpack.fleet.exportCSV.unenrolledReasonField', { + defaultMessage: 'Unenrolled Reason', + }), + }, + { + field: 'unenrollment_started_at', + description: i18n.translate('xpack.fleet.exportCSV.unenrolledStartedAtField', { + defaultMessage: 'Unenrolled Started At', + }), + }, + { + field: 'unhealthy_reason', + description: i18n.translate('xpack.fleet.exportCSV.unhealthyReasonField', { + defaultMessage: 'Unhealthy Reason', + }), + }, + { + field: 'updated_at', + description: i18n.translate('xpack.fleet.exportCSV.updatedAtField', { + defaultMessage: 'Updated At', + }), + }, + { + field: 'upgrade_started_at', + description: i18n.translate('xpack.fleet.exportCSV.upgradeStartedAtField', { + defaultMessage: 'Upgrade Started At', + }), + }, + { + field: 'upgrade_status', + description: i18n.translate('xpack.fleet.exportCSV.upgradeStatusField', { + defaultMessage: 'Upgrade Status', + }), + }, + { + field: 'upgraded_at', + description: i18n.translate('xpack.fleet.exportCSV.upgradedAtField', { + defaultMessage: 'Upgraded At', + }), + }, + { + field: 'user_provided_metadata', + description: i18n.translate('xpack.fleet.exportCSV.userProvidedMetadataField', { + defaultMessage: 'User Provided Metadata', + }), + }, +]; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_export_csv_modal/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_export_csv_modal/index.tsx new file mode 100644 index 0000000000000..901a90d5dea68 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_export_csv_modal/index.tsx @@ -0,0 +1,139 @@ +/* + * 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, { useState } from 'react'; +import type { EuiBasicTableColumn, EuiSearchBarProps, EuiTableSelectionType } from '@elastic/eui'; +import { + EuiConfirmModal, + EuiFlexGroup, + EuiFlexItem, + EuiInMemoryTable, + EuiNotificationBadge, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { uniqBy } from 'lodash'; + +import { AGENT_FIELDS_TO_EXPORT, INITIAL_AGENT_FIELDS_TO_EXPORT } from './columns'; + +export interface ExportField { + field: string; +} + +export interface ExportFieldWithDescription extends ExportField { + description: string; +} + +interface Props { + onClose: () => void; + onSubmit: (columns: ExportField[]) => void; + agentCount: number; +} + +export const AgentExportCSVModal: React.FunctionComponent = ({ + onClose, + onSubmit, + agentCount, +}) => { + const [selection, setSelection] = useState( + INITIAL_AGENT_FIELDS_TO_EXPORT + ); + + const items = uniqBy([...INITIAL_AGENT_FIELDS_TO_EXPORT, ...AGENT_FIELDS_TO_EXPORT], 'field'); + + const columns: Array> = [ + { + field: 'field', + name: 'Field', + truncateText: true, + }, + { + field: 'description', + name: 'Description', + truncateText: true, + }, + ]; + + const selectionValue: EuiTableSelectionType = { + selectable: () => true, + onSelectionChange: (newSelection) => { + setSelection(newSelection); + }, + initialSelected: INITIAL_AGENT_FIELDS_TO_EXPORT, + }; + + const search: EuiSearchBarProps = { + box: { + incremental: true, + }, + }; + + return ( + + } + onCancel={onClose} + onConfirm={() => onSubmit(selection.map((s) => ({ field: s.field })))} + cancelButtonText={ + + } + confirmButtonText={ + + } + > + + + + + + + + + + + {agentCount} + + + + + + + + + + + + + + + + + + ); +};