Skip to content

Commit

Permalink
[Fleet] Added Export CSV bulk action to Agent list (elastic#196635)
Browse files Browse the repository at this point in the history
## Summary

Closes elastic/ingest-dev#4326

- Added bulk action to Export agents to CSV
- Passing selected filters to export selected agents 
- Passing sort order and direction to match the sort config in the agent
list
- Columns are hardcoded for now, column selection will come in another
issue: elastic/ingest-dev#4325
  - Didn't find a way to make the exported column names more readable
- Agent policy name is not enriched yet, will do as a follow up after
elastic/ingest-dev#4442 is done
- Agent status is calculated by the runtime field (script queried from
the backend)
- We might be able to replace this too with an enriched field like agent
policy name

Added backport to 8.x, as the feature is hidden behind a feature flag. 

To verify:
- enable feature flag in `kibana.dev.yml`:
`xpack.fleet.enableExperimental: ['enableExportCSV']`
- use the `create_agents` script to create a few agent docs
```
node scripts/create_agents --count 20  --kibana http://localhost:5601 --status offline,online,inactive,error,updating,unenrolled  --inactivityTimeout 36000 
```
- filter the agent list and change sort order
- select a few agents / select all
- click on `Export X agents as CSV` in bulk actions
- click on the toast message to go to Reporting
- download the report and compare the results to check that the exported
csv has the same number of rows and same sort order as the selected
agent list

<img width="1324" alt="image"
src="https://github.com/user-attachments/assets/ec27f3a2-46ae-4a08-ad42-7c668b50a57e">
<img width="1538" alt="image"
src="https://github.com/user-attachments/assets/cfd60ca1-2bd9-483d-941a-366dec2cea64">
<img width="1326" alt="image"
src="https://github.com/user-attachments/assets/233ba0d1-1960-4108-aabf-2b95247ec3a7">
<img width="1202" alt="image"
src="https://github.com/user-attachments/assets/187de485-4c5c-4a95-9e22-e2b7771be081">

---------

Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
2 people authored and CAWilson94 committed Nov 18, 2024
1 parent 0fef3bd commit 4d451f0
Show file tree
Hide file tree
Showing 18 changed files with 529 additions and 20 deletions.
1 change: 1 addition & 0 deletions x-pack/plugins/fleet/common/experimental_features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const _allowedExperimentalValues = {
useSpaceAwareness: false,
enableReusableIntegrationPolicies: true,
asyncDeployPolicies: true,
enableExportCSV: false,
};

/**
Expand Down
2 changes: 2 additions & 0 deletions x-pack/plugins/fleet/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ export {
INVALID_NAMESPACE_CHARACTERS,
getFileMetadataIndexName,
getFileDataIndexName,
removeSOAttributes,
getSortConfig,
} from './services';

export type { FleetAuthz } from './authz';
Expand Down
29 changes: 29 additions & 0 deletions x-pack/plugins/fleet/common/services/agent_utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* 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 { getSortConfig, removeSOAttributes } from './agent_utils';

describe('Agent utils', () => {
it('should get sort config', () => {
const sortConfig = getSortConfig('agent.id', 'asc');
expect(sortConfig).toEqual([{ 'agent.id': { order: 'asc' } }]);
});

it('should get default sort config', () => {
const sortConfig = getSortConfig('enrolled_at', 'desc');
expect(sortConfig).toEqual([
{ enrolled_at: { order: 'desc' } },
{ 'local_metadata.host.hostname.keyword': { order: 'asc' } },
]);
});

it('should remove SO attributes', () => {
const kuery = 'attributes.test AND fleet-agents.test';
const result = removeSOAttributes(kuery);
expect(result).toEqual('test AND test');
});
});
22 changes: 22 additions & 0 deletions x-pack/plugins/fleet/common/services/agent_utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* 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 function removeSOAttributes(kuery: string): string {
return kuery.replace(/attributes\./g, '').replace(/fleet-agents\./g, '');
}

export function getSortConfig(
sortField: string,
sortOrder: 'asc' | 'desc'
): Array<Record<string, { order: 'asc' | 'desc' }>> {
const isDefaultSort = sortField === 'enrolled_at' && sortOrder === 'desc';
// if using default sorting (enrolled_at), adding a secondary sort on hostname, so that the results are not changing randomly in case many agents were enrolled at the same time
const secondarySort: Array<Record<string, { order: 'asc' | 'desc' }>> = isDefaultSort
? [{ 'local_metadata.host.hostname.keyword': { order: 'asc' } }]
: [];
return [{ [sortField]: { order: sortOrder } }, ...secondarySort];
}
2 changes: 2 additions & 0 deletions x-pack/plugins/fleet/common/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,5 @@ export {
getFleetServerVersionMessage,
isAgentVersionLessThanFleetServer,
} from './check_fleet_server_versions';

export { removeSOAttributes, getSortConfig } from './agent_utils';
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ const mockedUseLicence = useLicense as jest.MockedFunction<typeof useLicense>;

jest.mock('../../components/agent_reassign_policy_modal');

jest.mock('../hooks/export_csv', () => ({
useExportCSV: jest.fn().mockReturnValue({
generateReportingJobCSV: jest.fn(),
}),
}));

const defaultProps = {
nAgentsInTable: 10,
totalManagedAgentIds: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import { getCommonTags } from '../utils';

import { AgentRequestDiagnosticsModal } from '../../components/agent_request_diagnostics_modal';

import { useExportCSV } from '../hooks/export_csv';

import type { SelectionMode } from './types';
import { TagsAddRemove } from './tags_add_remove';

Expand All @@ -44,6 +46,8 @@ export interface Props {
refreshAgents: (args?: { refreshTags?: boolean }) => void;
allTags: string[];
agentPolicies: AgentPolicy[];
sortField?: string;
sortOrder?: 'asc' | 'desc';
}

export const AgentBulkActions: React.FunctionComponent<Props> = ({
Expand All @@ -56,6 +60,8 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
refreshAgents,
allTags,
agentPolicies,
sortField,
sortOrder,
}) => {
const licenseService = useLicense();
const isLicenceAllowingScheduleUpgrade = licenseService.hasAtLeast(LICENSE_FOR_SCHEDULE_UPGRADE);
Expand Down Expand Up @@ -96,7 +102,9 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
: nAgentsInTable - totalManagedAgentIds?.length;

const [tagsPopoverButton, setTagsPopoverButton] = useState<HTMLElement>();
const { diagnosticFileUploadEnabled } = ExperimentalFeaturesService.get();
const { diagnosticFileUploadEnabled, enableExportCSV } = ExperimentalFeaturesService.get();

const { generateReportingJobCSV } = useExportCSV(enableExportCSV);

const menuItems = [
{
Expand Down Expand Up @@ -217,6 +225,30 @@ export const AgentBulkActions: React.FunctionComponent<Props> = ({
setIsUnenrollModalOpen(true);
},
},
...(enableExportCSV
? [
{
name: (
<FormattedMessage
id="xpack.fleet.agentBulkActions.exportAgents"
data-test-subj="bulkAgentExportBtn"
defaultMessage="Export {agentCount, plural, one {# agent} other {# agents}} as CSV"
values={{
agentCount,
}}
/>
),
icon: <EuiIcon type="exportAction" size="m" />,
onClick: () => {
closeMenu();
generateReportingJobCSV(agents, {
field: sortField,
direction: sortOrder,
});
},
},
]
: []),
];

const panels = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export interface SearchAndFilterBarProps {
onClickAgentActivity: () => void;
showAgentActivityTour: { isOpen: boolean };
latestAgentActionErrors: number;
sortField?: string;
sortOrder?: 'asc' | 'desc';
}

export const SearchAndFilterBar: React.FunctionComponent<SearchAndFilterBarProps> = ({
Expand Down Expand Up @@ -89,6 +91,8 @@ export const SearchAndFilterBar: React.FunctionComponent<SearchAndFilterBarProps
onClickAgentActivity,
showAgentActivityTour,
latestAgentActionErrors,
sortField,
sortOrder,
}) => {
const authz = useAuthz();

Expand Down Expand Up @@ -219,6 +223,8 @@ export const SearchAndFilterBar: React.FunctionComponent<SearchAndFilterBarProps
refreshAgents={refreshAgents}
allTags={tags}
agentPolicies={agentPolicies}
sortField={sortField}
sortOrder={sortOrder}
/>
</EuiFlexItem>
) : null}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/*
* 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 { RenderHookResult } from '@testing-library/react-hooks';
import { act } from '@testing-library/react-hooks';

import { createFleetTestRendererMock } from '../../../../../../mock';

import type { Agent } from '../../../../../../../common';

import { useExportCSV } from './export_csv';

jest.mock('../../../../../../hooks', () => ({
useGetAgentStatusRuntimeFieldQuery: jest.fn().mockReturnValue({
data: 'emit("offline")',
isLoading: false,
}),
useKibanaVersion: jest.fn().mockReturnValue('9.0.0'),
useStartServices: jest.fn().mockReturnValue({
notifications: {
toasts: {
addSuccess: jest.fn(),
addError: jest.fn(),
},
},
http: {},
uiSettings: {},
}),
}));

const mockGetDecoratedJobParams = jest.fn().mockImplementation((params) => params);
const mockCreateReportingShareJob = jest.fn().mockResolvedValue({});

jest.mock('@kbn/reporting-public', () => ({
ReportingAPIClient: jest.fn().mockImplementation(() => ({
getDecoratedJobParams: mockGetDecoratedJobParams,
createReportingShareJob: mockCreateReportingShareJob,
})),
}));

describe('export_csv', () => {
let result: RenderHookResult<any, any>;

function render() {
const renderer = createFleetTestRendererMock();
return renderer.renderHook(() => useExportCSV(true));
}

beforeEach(() => {
jest.clearAllMocks();
act(() => {
result = render();
});
});

it('should generate reporting job for export csv with agent ids', () => {
const agents = [{ id: 'agent1' }, { id: 'agent2' }] as Agent[];
const sortOptions = {
field: 'agent.id',
direction: 'asc',
};

act(() => {
result.result.current.generateReportingJobCSV(agents, sortOptions);
});

expect(mockGetDecoratedJobParams.mock.calls[0][0].columns.length).toEqual(6);
expect(mockGetDecoratedJobParams.mock.calls[0][0].searchSource).toEqual(
expect.objectContaining({
filter: expect.objectContaining({
query: {
bool: {
minimum_should_match: 1,
should: [
{
bool: {
minimum_should_match: 1,
should: [
{
match: {
'agent.id': 'agent1',
},
},
],
},
},
{
bool: {
minimum_should_match: 1,
should: [
{
match: {
'agent.id': 'agent2',
},
},
],
},
},
],
},
},
}),
index: expect.objectContaining({
runtimeFieldMap: {
status: {
script: {
source: 'emit("offline")',
},
type: 'keyword',
},
},
}),
sort: [
{
'agent.id': {
order: 'asc',
},
},
],
})
);
expect(mockCreateReportingShareJob).toHaveBeenCalled();
});

it('should generate reporting job for export csv with agents query', () => {
const agents = 'policy_id:1 AND status:online';

act(() => {
result.result.current.generateReportingJobCSV(agents, undefined);
});

expect(mockGetDecoratedJobParams.mock.calls[0][0].columns.length).toEqual(6);
expect(mockGetDecoratedJobParams.mock.calls[0][0].searchSource).toEqual(
expect.objectContaining({
filter: expect.objectContaining({
query: {
bool: {
filter: [
{
bool: {
minimum_should_match: 1,
should: [
{
match: {
policy_id: '1',
},
},
],
},
},
{
bool: {
minimum_should_match: 1,
should: [
{
match: {
status: 'online',
},
},
],
},
},
],
},
},
}),
sort: [
{
enrolled_at: {
order: 'desc',
},
},
{
'local_metadata.host.hostname.keyword': {
order: 'asc',
},
},
],
})
);
expect(mockCreateReportingShareJob).toHaveBeenCalled();
});
});
Loading

0 comments on commit 4d451f0

Please sign in to comment.