Skip to content

Commit

Permalink
Added limit to FIM csv export (#7182)
Browse files Browse the repository at this point in the history
* Added tip on fim

* Added Changelog

* Snaps updated

* Update alert message

* Add limit of 10000 rows

* Add warning message and sorting filter

* Update changelog

* Add constant to set the limit

* Add constant to server side

* Remove duplicated SVG elements in inventory and agents table snapshots for cleaner tests

* Add constant to wazuh-core

* Fix sort state

* Add option to set the max rows of csv reports

* Add logic to read the max rows from configuration in frontend

* Add logic to read rows limit from configuration in backend

* Update changelog

* Remove unused constants

* Update test

* Update tests

* Remove unnecessary values

* Remove console logs

* Replace icon

* Fix spacing at button sides

* Fix formatting

---------

Co-authored-by: Guido Modarelli <[email protected]>
Co-authored-by: Nicolas Agustin Guevara Pihen <[email protected]>
Co-authored-by: Guido Modarelli <[email protected]>
Co-authored-by: Federico Rodriguez <[email protected]>
  • Loading branch information
5 people authored Dec 13, 2024
1 parent 8a128e9 commit c4fff72
Show file tree
Hide file tree
Showing 9 changed files with 178 additions and 56 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ All notable changes to the Wazuh app project will be documented in this file.
### Added

- Support for Wazuh 4.10.2
- Add setting to limit the number of rows in csv reports [#7182](https://github.com/wazuh/wazuh-dashboard-plugins/pull/7182)

### Changed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ import { SyscollectorInventory } from './inventory';
import { AgentTabs } from '../../endpoints-summary/agent/agent-tabs';
import { queryDataTestAttr } from '../../../../test/public/query-attr';

jest.mock('../../common/hooks/use-app-config', () => ({
useAppConfig: () => ({
isReady: true,
isLoading: false,
data: {
'reports.csv.maxRows': 10000,
},
}),
}));

const TABLE_ID = '__table_7d62db31-1cd0-11ee-8e0c-33242698a3b9';
const SOFTWARE_PACKAGES = 'Packages';
const SOFTWARE_WINDOWS_UPDATES = 'Windows updates';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,20 @@
*/

import React from 'react';
import {
EuiFlexItem,
EuiButtonEmpty
} from '@elastic/eui';
import { EuiFlexItem, EuiButtonEmpty, EuiIconTip } from '@elastic/eui';
import exportCsv from '../../../../react-services/wz-csv';
import { getToasts } from '../../../../kibana-services';
import { getToasts } from '../../../../kibana-services';
import { UI_ERROR_SEVERITIES } from '../../../../react-services/error-orchestrator/types';
import { UI_LOGGER_LEVELS } from '../../../../../common/constants';
import { getErrorOrchestrator } from '../../../../react-services/common-services';

export function ExportTableCsv({ endpoint, totalItems, filters, title }) {

export function ExportTableCsv({
endpoint,
totalItems,
filters,
title,
maxRows,
}) {
const showToast = (color, title, time) => {
getToasts().add({
color: color,
Expand All @@ -33,15 +35,12 @@ export function ExportTableCsv({ endpoint, totalItems, filters, title }) {

const downloadCsv = async () => {
try {
const formatedFilters = Object.entries(filters).map(([name, value]) => ({name, value}));
const formatedFilters = Object.entries(filters).map(([name, value]) => ({
name,
value,
}));
showToast('success', 'Your download should begin automatically...', 3000);
await exportCsv(
endpoint,
[
...formatedFilters
],
`${(title).toLowerCase()}`
);
await exportCsv(endpoint, [...formatedFilters], `${title.toLowerCase()}`);
} catch (error) {
const options = {
context: `${ExportTableCsv.name}.downloadCsv`,
Expand All @@ -55,19 +54,36 @@ export function ExportTableCsv({ endpoint, totalItems, filters, title }) {
};
getErrorOrchestrator().handleError(options);
}
}

return <EuiFlexItem grow={false}>
<EuiButtonEmpty isDisabled={(totalItems == 0)} iconType="importAction" onClick={() => downloadCsv()}>
Export formatted
</EuiButtonEmpty>
};

return (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
isDisabled={totalItems == 0}
iconType='importAction'
onClick={() => downloadCsv()}
>
Export formatted
{totalItems > maxRows && (
<>
{' '}
<EuiIconTip
content={`The exported CSV will be limited to the first ${maxRows} lines. You can change this limit in Dashboard management > App Settings`}
size='m'
color='primary'
type='iInCircle'
/>
</>
)}
</EuiButtonEmpty>
</EuiFlexItem>
);
}

// Set default props
ExportTableCsv.defaultProps = {
endpoint:'/',
totalItems:0,
filters: [],
title:""
};
endpoint: '/',
totalItems: 0,
filters: [],
title: '',
};
13 changes: 13 additions & 0 deletions plugins/main/public/components/common/tables/table-wz-api.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
import React from 'react';
import { mount } from 'enzyme';
import { TableWzAPI } from './table-wz-api';
import { useAppConfig, useStateStorage } from '../hooks';

jest.mock('../hooks', () => ({
useAppConfig: jest.fn(),
useStateStorage: jest.fn(),
}));

jest.mock('../../../kibana-services', () => ({
getHttp: () => ({
Expand Down Expand Up @@ -64,6 +70,13 @@ const columns = [

describe('Table WZ API component', () => {
it('renders correctly to match the snapshot', () => {
(useAppConfig as jest.Mock).mockReturnValue({
data: {
'reports.csv.maxRows': 10000,
},
});
(useStateStorage as jest.Mock).mockReturnValue([[], jest.fn()]);

const wrapper = mount(
<TableWzAPI
title='Table'
Expand Down
69 changes: 43 additions & 26 deletions plugins/main/public/components/common/tables/table-wz-api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ import { TableWithSearchBar } from './table-with-search-bar';
import { TableDefault } from './table-default';
import { WzRequest } from '../../../react-services/wz-request';
import { ExportTableCsv } from './components/export-table-csv';
import { useStateStorage } from '../hooks';

import { useStateStorage, useAppConfig } from '../hooks';
/**
* Search input custom filter button
*/
Expand All @@ -37,11 +36,22 @@ interface CustomFilterButton {
value: string;
}

const getFilters = filters => {
interface Filters {
[key: string]: string;
}

const getFilters = (filters: Filters) => {
const { default: defaultFilters, ...restFilters } = filters;
return Object.keys(restFilters).length ? restFilters : defaultFilters;
};

const formatSorting = sorting => {
if (!sorting.field || !sorting.direction) {
return '';
}
return `${sorting.direction === 'asc' ? '+' : '-'}${sorting.field}`;
};

export function TableWzAPI({
actionButtons,
postActionButtons,
Expand All @@ -53,12 +63,11 @@ export function TableWzAPI({
actionButtons?:
| ReactNode
| ReactNode[]
| (({ filters }: { filters }) => ReactNode);
| (({ filters }: { filters: Filters }) => ReactNode);
postActionButtons?:
| ReactNode
| ReactNode[]
| (({ filters }: { filters }) => ReactNode);

| (({ filters }: { filters: Filters }) => ReactNode);
title?: string;
addOnTitle?: ReactNode;
description?: string;
Expand All @@ -67,18 +76,18 @@ export function TableWzAPI({
searchTable?: boolean;
endpoint: string;
buttonOptions?: CustomFilterButton[];
onFiltersChange?: Function;
onFiltersChange?: (filters: Filters) => void;
showReload?: boolean;
searchBarProps?: any;
reload?: boolean;
onDataChange?: Function;
setReload?: (newValue: number) => void;
}) {
const [totalItems, setTotalItems] = useState(0);
const [filters, setFilters] = useState({});
const [filters, setFilters] = useState<Filters>({});
const [isLoading, setIsLoading] = useState(false);

const onFiltersChange = filters =>
const [sort, setSort] = useState({});
const onFiltersChange = (filters: Filters) =>
typeof rest.onFiltersChange === 'function'
? rest.onFiltersChange(filters)
: null;
Expand All @@ -101,26 +110,26 @@ export function TableWzAPI({
: undefined,
);
const [isOpenFieldSelector, setIsOpenFieldSelector] = useState(false);

const appConfig = useAppConfig();
const maxRows = appConfig.data['reports.csv.maxRows'];
const onSearch = useCallback(async function (
endpoint,
filters,
filters: Filters,
pagination,
sorting,
) {
try {
const { pageIndex, pageSize } = pagination;
const { field, direction } = sorting.sort;
setSort(sorting.sort);
setIsLoading(true);
setFilters(filters);
onFiltersChange(filters);
const params = {
...getFilters(filters),
offset: pageIndex * pageSize,
limit: pageSize,
sort: `${direction === 'asc' ? '+' : '-'}${field}`,
sort: formatSorting(sorting.sort),
};

const response = await WzRequest.apiReq('GET', endpoint, { params });

const { affected_items: items, total_affected_items: totalItems } = (
Expand Down Expand Up @@ -182,7 +191,9 @@ export function TableWzAPI({
};

useEffect(() => {
if (rest.reload) triggerReload();
if (rest.reload) {
triggerReload();
}
}, [rest.reload]);

const ReloadButton = (
Expand Down Expand Up @@ -227,16 +238,22 @@ export function TableWzAPI({
{rest.showReload && ReloadButton}
{/* Render optional export to CSV button */}
{rest.downloadCsv && (
<ExportTableCsv
endpoint={rest.endpoint}
totalItems={totalItems}
filters={getFilters(filters)}
title={
typeof rest.downloadCsv === 'string'
? rest.downloadCsv
: rest.title
}
/>
<>
<ExportTableCsv
endpoint={rest.endpoint}
totalItems={totalItems}
filters={getFilters({
...filters,
sort: formatSorting(sort),
})}
title={
typeof rest.downloadCsv === 'string'
? rest.downloadCsv
: rest.title
}
maxRows={maxRows}
/>
</>
)}
{/* Render optional post custom action button */}
{renderActionButtons(postActionButtons, filters)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ import { WzRequest } from '../../../react-services/wz-request';
import configureMockStore from 'redux-mock-store';
import { Provider } from 'react-redux';

jest.mock('../../common/hooks/use-app-config', () => ({
useAppConfig: () => ({
isReady: true,
isLoading: false,
data: {
'reports.csv.maxRows': 10000,
},
}),
}));

const data = [
{
id: '001',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ import { ModuleMitreAttackIntelligence } from './intelligence';
import configureMockStore from 'redux-mock-store';
import { Provider } from 'react-redux';

jest.mock('../../../common/hooks/use-app-config', () => ({
useAppConfig: () => ({
isReady: true,
isLoading: false,
data: {
'reports.csv.maxRows': 10000,
},
}),
}));
jest.mock(
'../../../../../../../node_modules/@elastic/eui/lib/services/accessibility/html_id_generator',
() => ({
Expand Down
19 changes: 15 additions & 4 deletions plugins/main/server/controllers/wazuh-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,8 @@ export class WazuhApiCtrl {
request: OpenSearchDashboardsRequest,
response: OpenSearchDashboardsResponseFactory,
) {
const appConfig = await context.wazuh_core.configuration.get();
const reportMaxRows = appConfig['reports.csv.maxRows'];
try {
if (!request.body || !request.body.path)
throw new Error('Field path is required');
Expand Down Expand Up @@ -782,16 +784,25 @@ export class WazuhApiCtrl {

if (totalItems && !isList) {
params.offset = 0;
itemsArray.push(...output.data.data.affected_items);
while (itemsArray.length < totalItems && params.offset < totalItems) {
params.offset += params.limit;
while (
itemsArray.length < Math.min(totalItems, reportMaxRows) &&
params.offset < Math.min(totalItems, reportMaxRows)
) {
const tmpData = await context.wazuh.api.client.asCurrentUser.request(
'GET',
`/${tmpPath}`,
{ params: params },
{ apiHostID: request.body.id },
);
itemsArray.push(...tmpData.data.data.affected_items);

const affectedItems = tmpData.data.data.affected_items;
const remainingItems = reportMaxRows - itemsArray.length;
if (itemsArray.length + affectedItems.length > reportMaxRows) {
itemsArray.push(...affectedItems.slice(0, remainingItems));
break;
}
itemsArray.push(...affectedItems);
params.offset += params.limit;
}
}

Expand Down
Loading

0 comments on commit c4fff72

Please sign in to comment.