From 6e145f9d4ebb17b8eefd01ce688f7c2b9b461172 Mon Sep 17 00:00:00 2001 From: Julia Bardi <90178898+juliaElastic@users.noreply.github.com> Date: Tue, 10 Dec 2024 12:54:51 +0100 Subject: [PATCH] [Fleet] Feature to support columns when exporting agents to CSV (#203103) ## Summary Closes https://github.com/elastic/ingest-dev/issues/4325 Added modal window with column selector table when agents are exported. There are some differences compared to the design due to technical limitations: - `Filters applied` badge not included, it's not really possible to accurately calculate the count of filters, since they are stored in a single string. We could potentially count by splitting AND/OR conditions, but it may not be accurate. Do we still want to include it? - We don't have display names for these mappings coming from the agent index, and currently I don't have a way to show display names on the exported file. For this reason showing the original field names on the modal too. - Added a Description column that transforms the field name to a more readable name, we can also hardcode the descriptions if this is not good enough. - After some consideration, I decided to hardcode the allowed field list to export other than querying dynamically from the agent index mappings, otherwise new sensitive field mappings would show up on the UI. - Caveat: searching on columns in the modal removes the already selected columns that do not match. This seems to be a default behaviour of the EuiTable, I find it a little strange, didn't see a straightforward way to change it. I can spend more time on it if needed. To verify: - Select a few agents, click on Export CSV action - Verify that the modal window opens with the agent mappings visible, by default the columns on the UI selected - Select a few columns to export - Submit the modal - Wait for the report to be ready and download it - Verify that the exported csv includes the columns selected image Figma design: ![image](https://github.com/user-attachments/assets/bcf347a7-a68e-4f83-8f6b-37bdf43c6b54) image ### Checklist - [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) - [ ] [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 --- .../fleet/common/experimental_features.ts | 2 +- .../components/bulk_actions.tsx | 25 +- .../agent_list_page/hooks/export_csv.test.tsx | 10 +- .../agent_list_page/hooks/export_csv.tsx | 19 +- .../agent_export_csv_modal/columns.ts | 234 ++++++++++++++++++ .../agent_export_csv_modal/index.tsx | 139 +++++++++++ 6 files changed, 408 insertions(+), 21 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_export_csv_modal/columns.ts create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_export_csv_modal/index.tsx 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} + + + + + + + + + + + + + + + + + + ); +};