[8.11] [Cloud Security] CloudSecurityDataTable component (#167587) (#168056)

# Backport

This will backport the following commits from `main` to `8.11`:
- [[Cloud Security] CloudSecurityDataTable component
(#167587)](https://github.com/elastic/kibana/pull/167587)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Paulo
Henrique","email":"paulo.henrique@elastic.co"},"sourceCommit":{"committedDate":"2023-10-05T03:57:17Z","message":"[Cloud
Security] CloudSecurityDataTable component (#167587)\n\n##
Summary\r\n\r\nThis PR introduces the Cloud Security DataTable
component, meant to\r\nreplace and consolidate the tables used in the
Cloud Security plugin\r\n\r\nThe CloudSecurityDataTable component is a
wrapper over the\r\n`<UnifiedDataTable/>` component. We made that
decision based on the\r\nnumber of features it provides for the users
plus support from the most\r\ncommon features such as Flyout, Sort,
Filtering, and Pagination to the\r\nmost advanced features like
Virtualization and DataView integration.\r\n\r\n- **Virtualization**:
Thanks to Virtualization, users can fetch more\r\ndata on the table than
the limit of `500`, and also use larger items per\r\npage if
desired.\r\n- **DataView integration**: This option allows users to
rename Columns\r\nas needed by setting custom labels in the
DataView.\r\n- **Column control**: Users can move columns and resize it
as needed,\r\nand that settings is saved on the local storage.\r\n-
**Row Height control**: Users can modify in the Advanced Settings
->\r\n`discover:rowHeight` the height of the row.\r\n\r\nBelow is a
Matrix comparing the features between the current Findings\r\ntable, the
Vulnerabilities Data Grid and the new
CloudSecurityDataTable.\r\n\r\nFeature | CloudSecurity DataTable (new >=
8.11) | Findings Table\r\n(current <= 8.10) | Vulnerabilities Data Grid
(current <= 8.10)\r\n-- | -- | -- | --\r\nTooltip on Cell Hover |  | 
| \r\nColumn Sorting |  |  | \r\nMulti Column Sorting |  |  |
\r\nColumn filtering |  |  | \r\nPagination |  |  | \r\nRows per
page |  |  | \r\nVirtualization (Support to load more data) |  |  |
\r\nCustom component on Column Header |  (only custom text) |  |
\r\nAdditional controls on the left |  |  | \r\nText truncation | 
|  | \r\nOverride Style |
 (https://github.com/elastic/kibana/pull/166994) |  |\r\n\r\nLoad
more button |  |  | \r\nResize column |  |  | \r\nReorder column |
 |  | \r\nFullScreen button |  |  | \r\nUser-defined row height |
 |  | \r\nexternal pagination control (enables Flyout pagination) | 
|  | \r\nuser-defined column renaming |  (using DataViews) |  |
\r\n\r\n\r\n### Regressions\r\n\r\nThere are some regressions such as
losing the ability to paginate on the\r\nFlyout and the table pagination
is no longer controlled by URL params,\r\nthat's because pagination is
controlled by an internal state in the\r\n`<UnifiedDataTable/>`
component, and we plan to re-enable those features\r\nagain in the
future by contributing to the
`<UnifiedDataTable/>`\r\ncomponent.\r\n\r\n###
Screenshots\r\n\r\n\r\n![image](a0d1f95a-adcc-4e58-9d3e-0adec3df8b3b)\r\n\r\n###
Videos\r\n\r\nBasic Features +
Virtualization\r\n\r\n\r\nd8e6106c-0ca3-4277-b78b-5ca482095ae1\r\n\r\n\r\nDataView
integration\r\n\r\n\r\n\r\n0d583243-bb86-45e4-baa5-dc63253da8f6\r\n\r\nRow
Height
Control\r\n\r\n\r\n\r\nb1d43609-7c8a-4855-ab2f-624c18663579\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Jordan
<51442161+JordanSh@users.noreply.github.com>","sha":"8d5dfafd8d06cc3096f9b72325032510aa498eab","branchLabelMapping":{"^v8.12.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:Cloud
Security","backport:prev-minor","v8.11.0","v8.12.0"],"number":167587,"url":"https://github.com/elastic/kibana/pull/167587","mergeCommit":{"message":"[Cloud
Security] CloudSecurityDataTable component (#167587)\n\n##
Summary\r\n\r\nThis PR introduces the Cloud Security DataTable
component, meant to\r\nreplace and consolidate the tables used in the
Cloud Security plugin\r\n\r\nThe CloudSecurityDataTable component is a
wrapper over the\r\n`<UnifiedDataTable/>` component. We made that
decision based on the\r\nnumber of features it provides for the users
plus support from the most\r\ncommon features such as Flyout, Sort,
Filtering, and Pagination to the\r\nmost advanced features like
Virtualization and DataView integration.\r\n\r\n- **Virtualization**:
Thanks to Virtualization, users can fetch more\r\ndata on the table than
the limit of `500`, and also use larger items per\r\npage if
desired.\r\n- **DataView integration**: This option allows users to
rename Columns\r\nas needed by setting custom labels in the
DataView.\r\n- **Column control**: Users can move columns and resize it
as needed,\r\nand that settings is saved on the local storage.\r\n-
**Row Height control**: Users can modify in the Advanced Settings
->\r\n`discover:rowHeight` the height of the row.\r\n\r\nBelow is a
Matrix comparing the features between the current Findings\r\ntable, the
Vulnerabilities Data Grid and the new
CloudSecurityDataTable.\r\n\r\nFeature | CloudSecurity DataTable (new >=
8.11) | Findings Table\r\n(current <= 8.10) | Vulnerabilities Data Grid
(current <= 8.10)\r\n-- | -- | -- | --\r\nTooltip on Cell Hover |  | 
| \r\nColumn Sorting |  |  | \r\nMulti Column Sorting |  |  |
\r\nColumn filtering |  |  | \r\nPagination |  |  | \r\nRows per
page |  |  | \r\nVirtualization (Support to load more data) |  |  |
\r\nCustom component on Column Header |  (only custom text) |  |
\r\nAdditional controls on the left |  |  | \r\nText truncation | 
|  | \r\nOverride Style |
 (https://github.com/elastic/kibana/pull/166994) |  |\r\n\r\nLoad
more button |  |  | \r\nResize column |  |  | \r\nReorder column |
 |  | \r\nFullScreen button |  |  | \r\nUser-defined row height |
 |  | \r\nexternal pagination control (enables Flyout pagination) | 
|  | \r\nuser-defined column renaming |  (using DataViews) |  |
\r\n\r\n\r\n### Regressions\r\n\r\nThere are some regressions such as
losing the ability to paginate on the\r\nFlyout and the table pagination
is no longer controlled by URL params,\r\nthat's because pagination is
controlled by an internal state in the\r\n`<UnifiedDataTable/>`
component, and we plan to re-enable those features\r\nagain in the
future by contributing to the
`<UnifiedDataTable/>`\r\ncomponent.\r\n\r\n###
Screenshots\r\n\r\n\r\n![image](a0d1f95a-adcc-4e58-9d3e-0adec3df8b3b)\r\n\r\n###
Videos\r\n\r\nBasic Features +
Virtualization\r\n\r\n\r\nd8e6106c-0ca3-4277-b78b-5ca482095ae1\r\n\r\n\r\nDataView
integration\r\n\r\n\r\n\r\n0d583243-bb86-45e4-baa5-dc63253da8f6\r\n\r\nRow
Height
Control\r\n\r\n\r\n\r\nb1d43609-7c8a-4855-ab2f-624c18663579\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Jordan
<51442161+JordanSh@users.noreply.github.com>","sha":"8d5dfafd8d06cc3096f9b72325032510aa498eab"}},"sourceBranch":"main","suggestedTargetBranches":["8.11"],"targetPullRequestStates":[{"branch":"8.11","label":"v8.11.0","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.12.0","labelRegex":"^v8.12.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/167587","number":167587,"mergeCommit":{"message":"[Cloud
Security] CloudSecurityDataTable component (#167587)\n\n##
Summary\r\n\r\nThis PR introduces the Cloud Security DataTable
component, meant to\r\nreplace and consolidate the tables used in the
Cloud Security plugin\r\n\r\nThe CloudSecurityDataTable component is a
wrapper over the\r\n`<UnifiedDataTable/>` component. We made that
decision based on the\r\nnumber of features it provides for the users
plus support from the most\r\ncommon features such as Flyout, Sort,
Filtering, and Pagination to the\r\nmost advanced features like
Virtualization and DataView integration.\r\n\r\n- **Virtualization**:
Thanks to Virtualization, users can fetch more\r\ndata on the table than
the limit of `500`, and also use larger items per\r\npage if
desired.\r\n- **DataView integration**: This option allows users to
rename Columns\r\nas needed by setting custom labels in the
DataView.\r\n- **Column control**: Users can move columns and resize it
as needed,\r\nand that settings is saved on the local storage.\r\n-
**Row Height control**: Users can modify in the Advanced Settings
->\r\n`discover:rowHeight` the height of the row.\r\n\r\nBelow is a
Matrix comparing the features between the current Findings\r\ntable, the
Vulnerabilities Data Grid and the new
CloudSecurityDataTable.\r\n\r\nFeature | CloudSecurity DataTable (new >=
8.11) | Findings Table\r\n(current <= 8.10) | Vulnerabilities Data Grid
(current <= 8.10)\r\n-- | -- | -- | --\r\nTooltip on Cell Hover |  | 
| \r\nColumn Sorting |  |  | \r\nMulti Column Sorting |  |  |
\r\nColumn filtering |  |  | \r\nPagination |  |  | \r\nRows per
page |  |  | \r\nVirtualization (Support to load more data) |  |  |
\r\nCustom component on Column Header |  (only custom text) |  |
\r\nAdditional controls on the left |  |  | \r\nText truncation | 
|  | \r\nOverride Style |
 (https://github.com/elastic/kibana/pull/166994) |  |\r\n\r\nLoad
more button |  |  | \r\nResize column |  |  | \r\nReorder column |
 |  | \r\nFullScreen button |  |  | \r\nUser-defined row height |
 |  | \r\nexternal pagination control (enables Flyout pagination) | 
|  | \r\nuser-defined column renaming |  (using DataViews) |  |
\r\n\r\n\r\n### Regressions\r\n\r\nThere are some regressions such as
losing the ability to paginate on the\r\nFlyout and the table pagination
is no longer controlled by URL params,\r\nthat's because pagination is
controlled by an internal state in the\r\n`<UnifiedDataTable/>`
component, and we plan to re-enable those features\r\nagain in the
future by contributing to the
`<UnifiedDataTable/>`\r\ncomponent.\r\n\r\n###
Screenshots\r\n\r\n\r\n![image](a0d1f95a-adcc-4e58-9d3e-0adec3df8b3b)\r\n\r\n###
Videos\r\n\r\nBasic Features +
Virtualization\r\n\r\n\r\nd8e6106c-0ca3-4277-b78b-5ca482095ae1\r\n\r\n\r\nDataView
integration\r\n\r\n\r\n\r\n0d583243-bb86-45e4-baa5-dc63253da8f6\r\n\r\nRow
Height
Control\r\n\r\n\r\n\r\nb1d43609-7c8a-4855-ab2f-624c18663579\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Jordan
<51442161+JordanSh@users.noreply.github.com>","sha":"8d5dfafd8d06cc3096f9b72325032510aa498eab"}}]}]
BACKPORT-->

Co-authored-by: Paulo Henrique <paulo.henrique@elastic.co>
This commit is contained in:
Kibana Machine 2023-10-05 14:00:05 -04:00 committed by GitHub
parent 6518d71387
commit 9a5c2dd363
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 911 additions and 745 deletions

View file

@ -11,6 +11,7 @@
"requiredPlugins": [
"navigation",
"data",
"dataViews",
"fleet",
"unifiedSearch",
"taskManager",
@ -19,7 +20,8 @@
"discover",
"cloud",
"licensing",
"share"
"share",
"kibanaUtils"
],
"optionalPlugins": ["usageCollection"],
"requiredBundles": ["kibanaReact", "usageCollection"]

View file

@ -1,53 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { DataView } from '@kbn/data-plugin/common';
import { DATA_VIEW_INDEX_PATTERN } from '../../../common/constants';
import { CspClientPluginStartDeps } from '../../types';
/**
* Returns the common logs-* data view with fields filtered by
* fields present in the given index pattern
*/
export const useFilteredDataView = (indexPattern: string) => {
const {
data: { dataViews },
} = useKibana<CspClientPluginStartDeps>().services;
const findDataView = async (): Promise<DataView> => {
const dataView = (await dataViews.find(DATA_VIEW_INDEX_PATTERN))?.[0];
if (!dataView) {
throw new Error('Findings data view not found');
}
const indexPatternFields = await dataViews.getFieldsForWildcard({
pattern: indexPattern,
});
if (!indexPatternFields) {
throw new Error('Error fetching fields for the index pattern');
}
// Filter out fields that are not present in the index pattern passed as a parameter
dataView.fields = dataView.fields.filter((field) =>
indexPatternFields.some((indexPatternField) => indexPatternField.name === field.name)
) as DataView['fields'];
// Insert fields that are present in the index pattern but not in the data view
indexPatternFields.forEach((indexPatternField) => {
if (!dataView.fields.some((field) => field.name === indexPatternField.name)) {
dataView.fields.push(indexPatternField as DataView['fields'][0]);
}
});
return dataView;
};
return useQuery(['latest_findings_data_view', indexPattern], findDataView);
};

View file

@ -8,8 +8,45 @@
import { useQuery } from '@tanstack/react-query';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { DataView } from '@kbn/data-plugin/common';
import { i18n } from '@kbn/i18n';
import { LATEST_FINDINGS_INDEX_PATTERN } from '../../../common/constants';
import { CspClientPluginStartDeps } from '../../types';
const cloudSecurityFieldLabels: Record<string, string> = {
'result.evaluation': i18n.translate(
'xpack.csp.findings.findingsTable.findingsTableColumn.resultColumnLabel',
{ defaultMessage: 'Result' }
),
'resource.id': i18n.translate(
'xpack.csp.findings.findingsTable.findingsTableColumn.resourceIdColumnLabel',
{ defaultMessage: 'Resource ID' }
),
'resource.name': i18n.translate(
'xpack.csp.findings.findingsTable.findingsTableColumn.resourceNameColumnLabel',
{ defaultMessage: 'Resource Name' }
),
'resource.sub_type': i18n.translate(
'xpack.csp.findings.findingsTable.findingsTableColumn.resourceTypeColumnLabel',
{ defaultMessage: 'Resource Type' }
),
'rule.benchmark.rule_number': i18n.translate(
'xpack.csp.findings.findingsTable.findingsTableColumn.ruleNumberColumnLabel',
{ defaultMessage: 'Rule Number' }
),
'rule.name': i18n.translate(
'xpack.csp.findings.findingsTable.findingsTableColumn.ruleNameColumnLabel',
{ defaultMessage: 'Rule Name' }
),
'rule.section': i18n.translate(
'xpack.csp.findings.findingsTable.findingsTableColumn.ruleSectionColumnLabel',
{ defaultMessage: 'CIS Section' }
),
'@timestamp': i18n.translate(
'xpack.csp.findings.findingsTable.findingsTableColumn.lastCheckedColumnLabel',
{ defaultMessage: 'Last Checked' }
),
} as const;
/**
* TODO: use perfected kibana data views
*/
@ -19,11 +56,23 @@ export const useLatestFindingsDataView = (dataView: string) => {
} = useKibana<CspClientPluginStartDeps>().services;
const findDataView = async (): Promise<DataView> => {
const dataViewObj = (await dataViews.find(dataView))?.[0];
const [dataViewObj] = await dataViews.find(dataView);
if (!dataViewObj) {
throw new Error(`Data view not found [Name: {${dataView}}]`);
}
if (dataView === LATEST_FINDINGS_INDEX_PATTERN) {
Object.entries(cloudSecurityFieldLabels).forEach(([field, label]) => {
if (
!dataViewObj.getFieldAttrs()[field]?.customLabel ||
dataViewObj.getFieldAttrs()[field]?.customLabel === field
) {
dataViewObj.setFieldCustomLabel(field, label);
}
});
await dataViews.updateSavedObject(dataViewObj);
}
return dataViewObj;
};

View file

@ -38,6 +38,8 @@ export const CSP_MOMENT_FORMAT = 'MMMM D, YYYY @ HH:mm:ss.SSS';
export const MAX_FINDINGS_TO_LOAD = 500;
export const DEFAULT_VISIBLE_ROWS_PER_PAGE = 25;
export const LOCAL_STORAGE_DATA_TABLE_PAGE_SIZE_KEY = 'cloudPosture:dataTable:pageSize';
export const LOCAL_STORAGE_DATA_TABLE_COLUMNS_KEY = 'cloudPosture:dataTable:columns';
export const LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY = 'cloudPosture:findings:pageSize';
export const LOCAL_STORAGE_PAGE_SIZE_BENCHMARK_KEY = 'cloudPosture:benchmark:pageSize';
export const LOCAL_STORAGE_PAGE_SIZE_RULES_KEY = 'cloudPosture:rules:pageSize';

View file

@ -8,19 +8,18 @@ import { Dispatch, SetStateAction, useCallback } from 'react';
import { type DataView } from '@kbn/data-views-plugin/common';
import { BoolQuery } from '@kbn/es-query';
import { CriteriaWithPagination } from '@elastic/eui';
import { DataTableRecord } from '@kbn/discover-utils/types';
import { useUrlQuery } from '../use_url_query';
import { usePageSize } from '../use_page_size';
import { getDefaultQuery, useBaseEsQuery, usePersistedQuery } from './utils';
interface QuerySort {
direction: string;
id: string;
}
import { LOCAL_STORAGE_DATA_TABLE_COLUMNS_KEY } from '../../constants';
export interface CloudPostureTableResult {
// TODO: Remove any when all finding tables are converted to CloudSecurityDataTable
setUrlQuery: (query: any) => void;
// TODO: remove any, this sorting is used for both EuiGrid and EuiTable which uses different types of sorts
// TODO: Remove any when all finding tables are converted to CloudSecurityDataTable
sort: any;
// TODO: Remove any when all finding tables are converted to CloudSecurityDataTable
filters: any[];
query?: { bool: BoolQuery };
queryError?: Error;
@ -28,13 +27,17 @@ export interface CloudPostureTableResult {
// TODO: remove any, urlQuery is an object with query fields but we also add custom fields to it, need to assert usages
urlQuery: any;
setTableOptions: (options: CriteriaWithPagination<object>) => void;
// TODO: Remove any when all finding tables are converted to CloudSecurityDataTable
handleUpdateQuery: (query: any) => void;
pageSize: number;
setPageSize: Dispatch<SetStateAction<number | undefined>>;
onChangeItemsPerPage: (newPageSize: number) => void;
onChangePage: (newPageIndex: number) => void;
onSort: (sort: QuerySort[]) => void;
// TODO: Remove any when all finding tables are converted to CloudSecurityDataTable
onSort: (sort: any) => void;
onResetFilters: () => void;
columnsLocalStorageKey: string;
getRowsFromPages: (data: Array<{ page: DataTableRecord[] }> | undefined) => DataTableRecord[];
}
/*
@ -44,10 +47,13 @@ export const useCloudPostureTable = ({
defaultQuery = getDefaultQuery,
dataView,
paginationLocalStorageKey,
columnsLocalStorageKey,
}: {
// TODO: Remove any when all finding tables are converted to CloudSecurityDataTable
defaultQuery?: (params: any) => any;
dataView: DataView;
paginationLocalStorageKey: string;
columnsLocalStorageKey?: string;
}): CloudPostureTableResult => {
const getPersistedDefaultQuery = usePersistedQuery(defaultQuery);
const { urlQuery, setUrlQuery } = useUrlQuery(getPersistedDefaultQuery);
@ -120,6 +126,13 @@ export const useCloudPostureTable = ({
[setUrlQuery]
);
const getRowsFromPages = (data: Array<{ page: DataTableRecord[] }> | undefined) =>
data
?.map(({ page }: { page: DataTableRecord[] }) => {
return page;
})
.flat() || [];
return {
setUrlQuery,
sort: urlQuery.sort,
@ -136,5 +149,7 @@ export const useCloudPostureTable = ({
onChangePage,
onSort,
onResetFilters,
columnsLocalStorageKey: columnsLocalStorageKey || LOCAL_STORAGE_DATA_TABLE_COLUMNS_KEY,
getRowsFromPages,
};
};

View file

@ -0,0 +1,275 @@
/*
* 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, useMemo } from 'react';
import { UnifiedDataTableSettings, useColumns } from '@kbn/unified-data-table';
import { type DataView } from '@kbn/data-views-plugin/common';
import { UnifiedDataTable, DataLoadingState } from '@kbn/unified-data-table';
import { CellActionsProvider } from '@kbn/cell-actions';
import {
ROW_HEIGHT_OPTION,
SHOW_MULTIFIELDS,
SORT_DEFAULT_ORDER_SETTING,
} from '@kbn/discover-utils';
import { DataTableRecord } from '@kbn/discover-utils/types';
import {
EuiDataGridCellValueElementProps,
EuiDataGridStyle,
EuiFlexItem,
EuiProgress,
} from '@elastic/eui';
import { AddFieldFilterHandler } from '@kbn/unified-field-list';
import { generateFilters } from '@kbn/data-plugin/public';
import { DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import numeral from '@elastic/numeral';
import { useKibana } from '../../common/hooks/use_kibana';
import { CloudPostureTableResult } from '../../common/hooks/use_cloud_posture_table';
import { FindingsGroupBySelector } from '../../pages/configurations/layout/findings_group_by_selector';
import { EmptyState } from '../empty_state';
import { MAX_FINDINGS_TO_LOAD } from '../../common/constants';
import { useStyles } from './use_styles';
export interface CloudSecurityDefaultColumn {
id: string;
}
const formatNumber = (value: number) => {
return value < 1000 ? value : numeral(value).format('0.0a');
};
const gridStyle: EuiDataGridStyle = {
border: 'horizontal',
cellPadding: 'l',
stripes: false,
header: 'underline',
};
const useNewFieldsApi = true;
interface CloudSecurityDataGridProps {
dataView: DataView;
isLoading: boolean;
defaultColumns: CloudSecurityDefaultColumn[];
rows: DataTableRecord[];
total: number;
/**
* This is the component that will be rendered in the flyout when a row is expanded.
* This component will receive the row data and a function to close the flyout.
*/
flyoutComponent: (hit: DataTableRecord, onCloseFlyout: () => void) => JSX.Element;
/**
* This is the object that contains all the data and functions from the useCloudPostureTable hook.
* This is also used to manage the table state from the parent component.
*/
cloudPostureTable: CloudPostureTableResult;
title: string;
/**
* This is a function that returns a map of column ids to custom cell renderers.
* This is useful for rendering custom components for cells in the table.
*/
customCellRenderer?: (rows: DataTableRecord[]) => {
[key: string]: (props: EuiDataGridCellValueElementProps) => JSX.Element;
};
/**
* Function to load more rows once the max number of rows has been reached.
*/
loadMore: () => void;
'data-test-subj'?: string;
}
export const CloudSecurityDataTable = ({
dataView,
isLoading,
defaultColumns,
rows,
total,
flyoutComponent,
cloudPostureTable,
loadMore,
title,
customCellRenderer,
...rest
}: CloudSecurityDataGridProps) => {
const {
columnsLocalStorageKey,
pageSize,
onChangeItemsPerPage,
setUrlQuery,
onSort,
onResetFilters,
filters,
sort,
} = cloudPostureTable;
const [columns, setColumns] = useLocalStorage(
columnsLocalStorageKey,
defaultColumns.map((c) => c.id)
);
const [settings, setSettings] = useLocalStorage<UnifiedDataTableSettings>(
`${columnsLocalStorageKey}:settings`,
{
columns: defaultColumns.reduce((prev, curr) => {
const newColumn = { [curr.id]: {} };
return { ...prev, ...newColumn };
}, {} as UnifiedDataTableSettings['columns']),
}
);
const [expandedDoc, setExpandedDoc] = useState<DataTableRecord | undefined>(undefined);
const renderDocumentView = (hit: DataTableRecord) =>
flyoutComponent(hit, () => setExpandedDoc(undefined));
// services needed for unified-data-table package
const {
uiSettings,
uiActions,
dataViews,
data,
application,
theme,
fieldFormats,
toastNotifications,
storage,
dataViewFieldEditor,
} = useKibana().services;
const styles = useStyles();
const { capabilities } = application;
const { filterManager } = data.query;
const services = {
theme,
fieldFormats,
uiSettings,
toastNotifications,
storage,
data,
dataViewFieldEditor,
};
const { columns: currentColumns, onSetColumns } = useColumns({
capabilities,
defaultOrder: uiSettings.get(SORT_DEFAULT_ORDER_SETTING),
dataView,
dataViews,
setAppState: (props) => setColumns(props.columns),
useNewFieldsApi,
columns,
sort,
});
const onAddFilter: AddFieldFilterHandler | undefined = useMemo(
() =>
filterManager && dataView
? (clickedField, values, operation) => {
const newFilters = generateFilters(
filterManager,
clickedField,
values,
operation,
dataView
);
filterManager.addFilters(newFilters);
setUrlQuery({
filters: filterManager.getFilters(),
});
}
: undefined,
[dataView, filterManager, setUrlQuery]
);
const onResize = (colSettings: { columnId: string; width: number }) => {
const grid = settings || {};
const newColumns = { ...(grid.columns || {}) };
newColumns[colSettings.columnId] = {
width: Math.round(colSettings.width),
};
const newGrid = { ...grid, columns: newColumns };
setSettings(newGrid);
};
const externalCustomRenderers = useMemo(() => {
if (!customCellRenderer) {
return undefined;
}
return customCellRenderer(rows);
}, [customCellRenderer, rows]);
if (!isLoading && !rows.length) {
return <EmptyState onResetFilters={onResetFilters} />;
}
return (
<CellActionsProvider getTriggerCompatibleActions={uiActions.getTriggerCompatibleActions}>
<div
data-test-subj={rest['data-test-subj']}
className={styles.gridContainer}
style={{
// Change the height of the grid to fit the page
// If there are filters, leave space for the filter bar
// Todo: Replace this component with EuiAutoSizer
height: `calc(100vh - ${filters.length > 0 ? 454 : 414}px)`,
}}
>
<EuiProgress
size="xs"
color="accent"
style={{
opacity: isLoading ? 1 : 0,
}}
/>
<UnifiedDataTable
className={styles.gridStyle}
ariaLabelledBy={title}
columns={currentColumns}
expandedDoc={expandedDoc}
dataView={dataView}
loadingState={isLoading ? DataLoadingState.loading : DataLoadingState.loaded}
onFilter={onAddFilter as DocViewFilterFn}
onResize={onResize}
onSetColumns={onSetColumns}
onSort={onSort}
rows={rows}
sampleSize={MAX_FINDINGS_TO_LOAD}
setExpandedDoc={setExpandedDoc}
renderDocumentView={renderDocumentView}
sort={sort}
rowsPerPageState={pageSize}
totalHits={total}
services={services}
useNewFieldsApi
onUpdateRowsPerPage={onChangeItemsPerPage}
configRowHeight={uiSettings.get(ROW_HEIGHT_OPTION)}
showMultiFields={uiSettings.get(SHOW_MULTIFIELDS)}
showTimeCol={false}
settings={settings}
onFetchMoreRecords={loadMore}
externalCustomRenderers={externalCustomRenderers}
rowHeightState={uiSettings.get(ROW_HEIGHT_OPTION)}
externalAdditionalControls={<AdditionalControls total={total} title={title} />}
gridStyleOverride={gridStyle}
/>
</div>
</CellActionsProvider>
);
};
const AdditionalControls = ({ total, title }: { total: number; title: string }) => {
const styles = useStyles();
return (
<>
<EuiFlexItem>
<span className="cspDataTableTotal">{`${formatNumber(total)} ${title}`}</span>
</EuiFlexItem>
<EuiFlexItem grow={false} className={styles.groupBySelector}>
<FindingsGroupBySelector type="default" />
</EuiFlexItem>
</>
);
};

View file

@ -0,0 +1,8 @@
/*
* 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 * from './cloud_security_data_table';

View file

@ -0,0 +1,85 @@
/*
* 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 { useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/css';
export const useStyles = () => {
const { euiTheme } = useEuiTheme();
const gridContainer = css`
min-height: 400px;
`;
const gridStyle = css`
& .euiDataGridHeaderCell__icon {
display: none;
}
& .euiDataGrid__controls {
border-bottom: none;
margin-bottom: ${euiTheme.size.s};
border-top: none;
& .euiButtonEmpty {
font-weight: ${euiTheme.font.weight.bold};
}
}
& .euiDataGrid--headerUnderline .euiDataGridHeaderCell {
border-bottom: ${euiTheme.border.width.thick} solid ${euiTheme.colors.fullShade};
}
& .euiDataGridRowCell__contentByHeight + .euiDataGridRowCell__expandActions {
padding: 0;
}
& .euiButtonIcon[data-test-subj='docTableExpandToggleColumn'] {
color: ${euiTheme.colors.primary};
}
& .euiDataGridRowCell {
font-size: ${euiTheme.size.m};
}
& .euiDataGridRowCell__expandFlex {
align-items: center;
}
& .euiDataGridRowCell.euiDataGridRowCell--numeric {
text-align: left;
}
& .euiDataGrid__controls {
gap: ${euiTheme.size.s};
}
& .euiDataGrid__leftControls {
display: flex;
align-items: center;
width: 100%;
}
& .cspDataTableTotal {
font-size: ${euiTheme.size.m};
font-weight: ${euiTheme.font.weight.bold};
}
& .euiDataGrid__rightControls {
display: none;
}
& [data-test-subj='docTableExpandToggleColumn'] svg {
inline-size: 16px;
block-size: 16px;
}
& .unifiedDataTable__cellValue {
font-family: ${euiTheme.font.family};
}
`;
const groupBySelector = css`
width: 188px;
margin-left: auto;
`;
return {
gridStyle,
groupBySelector,
gridContainer,
};
};

View file

@ -12,13 +12,10 @@ import { useLatestFindingsDataView } from '../../common/api/use_latest_findings_
import { Configurations } from './configurations';
import { TestProvider } from '../../test/test_provider';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks';
import { createStubDataView } from '@kbn/data-views-plugin/public/data_views/data_view.stub';
import { CSP_LATEST_FINDINGS_DATA_VIEW } from '../../../common/constants';
import * as TEST_SUBJECTS from './test_subjects';
import type { DataView } from '@kbn/data-plugin/common';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { discoverPluginMock } from '@kbn/discover-plugin/public/mocks';
import { useCspSetupStatusApi } from '../../common/api/use_setup_status_api';
import { useSubscriptionStatus } from '../../common/hooks/use_subscription_status';
import { createReactQueryResponse } from '../../test/fixtures/react_query';
@ -27,11 +24,9 @@ import { useCspIntegrationLink } from '../../common/navigation/use_csp_integrati
import { NO_FINDINGS_STATUS_TEST_SUBJ } from '../../components/test_subjects';
import { render } from '@testing-library/react';
import { expectIdsInDoc } from '../../test/utils';
import { fleetMock } from '@kbn/fleet-plugin/public/mocks';
import { licensingMock } from '@kbn/licensing-plugin/public/mocks';
import { PACKAGE_NOT_INSTALLED_TEST_SUBJECT } from '../../components/cloud_posture_page';
import { sharePluginMock } from '@kbn/share-plugin/public/mocks';
import { useLicenseManagementLocatorApi } from '../../common/api/use_license_management_locator_api';
import { useCloudPostureTable } from '../../common/hooks/use_cloud_posture_table';
jest.mock('../../common/api/use_latest_findings_data_view');
jest.mock('../../common/api/use_setup_status_api');
@ -39,6 +34,7 @@ jest.mock('../../common/api/use_license_management_locator_api');
jest.mock('../../common/hooks/use_subscription_status');
jest.mock('../../common/navigation/use_navigate_to_cis_integration_policies');
jest.mock('../../common/navigation/use_csp_integration_link');
jest.mock('../../common/hooks/use_cloud_posture_table');
const chance = new Chance();
@ -58,21 +54,18 @@ beforeEach(() => {
data: true,
})
);
(useCloudPostureTable as jest.Mock).mockImplementation(() => ({
getRowsFromPages: jest.fn(),
columnsLocalStorageKey: 'test',
filters: [],
sort: [],
}));
});
const renderFindingsPage = () => {
render(
<TestProvider
deps={{
data: dataPluginMock.createStartContract(),
unifiedSearch: unifiedSearchPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
discover: discoverPluginMock.createStartContract(),
fleet: fleetMock.createStartMock(),
licensing: licensingMock.createStart(),
share: sharePluginMock.createStartContract(),
}}
>
<TestProvider>
<Configurations />
</TestProvider>
);

View file

@ -76,9 +76,9 @@ type FindingsTab = typeof tabs[number];
interface FindingFlyoutProps {
onClose(): void;
findings: CspFinding;
flyoutIndex: number;
findingsCount: number;
onPaginate: (pageIndex: number) => void;
flyoutIndex?: number;
findingsCount?: number;
onPaginate?: (pageIndex: number) => void;
}
export const CodeBlock: React.FC<PropsOf<typeof EuiCodeBlock>> = (props) => (
@ -166,16 +166,22 @@ export const FindingsRuleFlyout = ({
<FindingsTab tab={tab} findings={findings} />
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup gutterSize="none" alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiPagination
aria-label={PAGINATION_LABEL}
pageCount={findingsCount}
activePage={flyoutIndex}
onPageClick={onPaginate}
compressed
/>
</EuiFlexItem>
<EuiFlexGroup
gutterSize="none"
alignItems="center"
justifyContent={onPaginate ? 'spaceBetween' : 'flexEnd'}
>
{onPaginate && (
<EuiFlexItem grow={false}>
<EuiPagination
aria-label={PAGINATION_LABEL}
pageCount={findingsCount}
activePage={flyoutIndex}
onPageClick={onPaginate}
compressed
/>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<TakeAction createRuleFn={createMisconfigurationRuleFn} />
</EuiFlexItem>

View file

@ -1,87 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { LatestFindingsContainer, getDefaultQuery } from './latest_findings_container';
import { createStubDataView } from '@kbn/data-views-plugin/common/mocks';
import { CSP_LATEST_FINDINGS_DATA_VIEW } from '../../../../common/constants';
import { DEFAULT_VISIBLE_ROWS_PER_PAGE } from '../../../common/constants';
import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { TestProvider } from '../../../test/test_provider';
import { getFindingsQuery } from './use_latest_findings';
import { encodeQuery } from '../../../common/navigation/query_utils';
import { useLocation } from 'react-router-dom';
import { buildEsQuery } from '@kbn/es-query';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { discoverPluginMock } from '@kbn/discover-plugin/public/mocks';
import { fleetMock } from '@kbn/fleet-plugin/public/mocks';
import { licensingMock } from '@kbn/licensing-plugin/public/mocks';
import { getPaginationQuery } from '../../../common/hooks/use_cloud_posture_table/utils';
import { sharePluginMock } from '@kbn/share-plugin/public/mocks';
jest.mock('../../../common/api/use_latest_findings_data_view');
jest.mock('../../../common/api/use_cis_kubernetes_integration');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: () => ({ push: jest.fn(), location: { pathname: '' } }),
useLocation: jest.fn(),
}));
beforeEach(() => {
jest.restoreAllMocks();
});
describe('<LatestFindingsContainer />', () => {
it('data#search.search fn called with URL query', () => {
const query = getDefaultQuery({
filters: [],
query: { language: 'kuery', query: '' },
});
const pageSize = DEFAULT_VISIBLE_ROWS_PER_PAGE;
const dataMock = dataPluginMock.createStartContract();
const dataView = createStubDataView({
spec: {
id: CSP_LATEST_FINDINGS_DATA_VIEW,
},
});
(useLocation as jest.Mock).mockReturnValue({
search: encodeQuery(query),
});
render(
<TestProvider
deps={{
data: dataMock,
unifiedSearch: unifiedSearchPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
discover: discoverPluginMock.createStartContract(),
fleet: fleetMock.createStartMock(),
licensing: licensingMock.createStart(),
share: sharePluginMock.createStartContract(),
}}
>
<LatestFindingsContainer dataView={dataView} />
</TestProvider>
);
const baseQuery = {
query: buildEsQuery(dataView, query.query, query.filters),
};
expect(dataMock.search.search).toHaveBeenNthCalledWith(1, {
params: getFindingsQuery({
...baseQuery,
...getPaginationQuery({ ...query, pageSize }),
sort: query.sort,
enabled: true,
}),
});
});
});

View file

@ -4,80 +4,140 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import React, { useMemo } from 'react';
import { EuiDataGridCellValueElementProps, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { DataTableRecord } from '@kbn/discover-utils/types';
import { Filter, Query } from '@kbn/es-query';
import { TimestampTableCell } from '../../../components/timestamp_table_cell';
import { CspEvaluationBadge } from '../../../components/csp_evaluation_badge';
import type { Evaluation } from '../../../../common/types';
import type { FindingsBaseProps, FindingsBaseURLQuery } from '../../../common/types';
import { FindingsTable } from './latest_findings_table';
import { FindingsSearchBar } from '../layout/findings_search_bar';
import * as TEST_SUBJECTS from '../test_subjects';
import { useLatestFindings } from './use_latest_findings';
import type { FindingsGroupByNoneQuery } from './use_latest_findings';
import { FindingsDistributionBar } from '../layout/findings_distribution_bar';
import { getFindingsPageSizeInfo, getFilters } from '../utils/utils';
import { LimitedResultsBar } from '../layout/findings_layout';
import { FindingsGroupBySelector } from '../layout/findings_group_by_selector';
import { usePageSlice } from '../../../common/hooks/use_page_slice';
import { getFilters } from '../utils/utils';
import { ErrorCallout } from '../layout/error_callout';
import { useLimitProperties } from '../../../common/utils/get_limit_properties';
import { LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY } from '../../../common/constants';
import { LOCAL_STORAGE_DATA_TABLE_PAGE_SIZE_KEY } from '../../../common/constants';
import { CspFinding } from '../../../../common/schemas/csp_finding';
import { useCloudPostureTable } from '../../../common/hooks/use_cloud_posture_table';
import { getPaginationTableParams } from '../../../common/hooks/use_cloud_posture_table/utils';
import {
CloudSecurityDataTable,
CloudSecurityDefaultColumn,
} from '../../../components/cloud_security_data_table';
import { FindingsRuleFlyout } from '../findings_flyout/findings_flyout';
export const getDefaultQuery = ({
const getDefaultQuery = ({
query,
filters,
}: FindingsBaseURLQuery): FindingsBaseURLQuery &
FindingsGroupByNoneQuery & { findingIndex: number } => ({
}: {
query: Query;
filters: Filter[];
}): FindingsBaseURLQuery & {
sort: string[][];
} => ({
query,
filters,
sort: { field: '@timestamp', direction: 'desc' },
pageIndex: 0,
findingIndex: -1,
sort: [['@timestamp', 'desc']],
});
const defaultColumns: CloudSecurityDefaultColumn[] = [
{ id: 'result.evaluation' },
{ id: 'resource.id' },
{ id: 'resource.name' },
{ id: 'resource.sub_type' },
{ id: 'rule.benchmark.rule_number' },
{ id: 'rule.name' },
{ id: 'rule.section' },
{ id: '@timestamp' },
];
/**
* Type Guard for checking if the given source is a CspFinding
*/
const isCspFinding = (source: Record<string, any> | undefined): source is CspFinding => {
return source?.result?.evaluation !== undefined;
};
/**
* This Wrapper component renders the children if the given row is a CspFinding
* it uses React's Render Props pattern
*/
const CspFindingRenderer = ({
row,
children,
}: {
row: DataTableRecord;
children: ({ finding }: { finding: CspFinding }) => JSX.Element;
}) => {
const source = row.raw._source;
const finding = isCspFinding(source) && (source as CspFinding);
if (!finding) return <></>;
return children({ finding });
};
/**
* Flyout component for the latest findings table
*/
const flyoutComponent = (row: DataTableRecord, onCloseFlyout: () => void): JSX.Element => {
return (
<CspFindingRenderer row={row}>
{({ finding }) => <FindingsRuleFlyout findings={finding} onClose={onCloseFlyout} />}
</CspFindingRenderer>
);
};
const columnsLocalStorageKey = 'cloudSecurityPostureLatestFindingsColumns';
const title = i18n.translate('xpack.csp.findings.latestFindings.tableRowTypeLabel', {
defaultMessage: 'Findings',
});
const customCellRenderer = (rows: DataTableRecord[]) => ({
'result.evaluation': ({ rowIndex }: EuiDataGridCellValueElementProps) => (
<CspFindingRenderer row={rows[rowIndex]}>
{({ finding }) => <CspEvaluationBadge type={finding.result.evaluation} />}
</CspFindingRenderer>
),
'@timestamp': ({ rowIndex }: EuiDataGridCellValueElementProps) => (
<CspFindingRenderer row={rows[rowIndex]}>
{({ finding }) => <TimestampTableCell timestamp={finding['@timestamp']} />}
</CspFindingRenderer>
),
});
export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => {
const {
pageIndex,
query,
sort,
queryError,
pageSize,
setTableOptions,
urlQuery,
setUrlQuery,
filters,
onResetFilters,
} = useCloudPostureTable({
const cloudPostureTable = useCloudPostureTable({
dataView,
paginationLocalStorageKey: LOCAL_STORAGE_DATA_TABLE_PAGE_SIZE_KEY,
columnsLocalStorageKey,
defaultQuery: getDefaultQuery,
paginationLocalStorageKey: LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY,
});
/**
* Page ES query result
*/
const findingsGroupByNone = useLatestFindings({
const { query, sort, queryError, setUrlQuery, filters, getRowsFromPages } = cloudPostureTable;
const {
data,
error: fetchError,
isFetching,
fetchNextPage,
} = useLatestFindings({
query,
sort,
enabled: !queryError,
});
const slicedPage = usePageSlice(findingsGroupByNone.data?.page, pageIndex, pageSize);
const rows = useMemo(() => getRowsFromPages(data?.pages), [data?.pages, getRowsFromPages]);
const error = findingsGroupByNone.error || queryError;
const error = fetchError || queryError;
const { isLastLimitedPage, limitedTotalItemCount } = useLimitProperties({
total: findingsGroupByNone.data?.total,
pageIndex,
pageSize,
});
const passed = data?.pages[0].count.passed || 0;
const failed = data?.pages[0].count.failed || 0;
const total = data?.pages[0].total || 0;
const handleDistributionClick = (evaluation: Evaluation) => {
setUrlQuery({
pageIndex: 0,
filters: getFilters({
filters,
dataView,
@ -88,117 +148,36 @@ export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => {
});
};
const flyoutFindingIndex = urlQuery?.findingIndex;
const pagination = getPaginationTableParams({
pageSize,
pageIndex,
totalItemCount: limitedTotalItemCount,
});
const onOpenFlyout = useCallback(
(flyoutFinding: CspFinding) => {
setUrlQuery({
findingIndex: slicedPage.findIndex(
(finding) =>
finding.resource.id === flyoutFinding?.resource.id &&
finding.rule.id === flyoutFinding?.rule.id
),
});
},
[slicedPage, setUrlQuery]
);
const onCloseFlyout = () =>
setUrlQuery({
findingIndex: -1,
});
const onPaginateFlyout = useCallback(
(nextFindingIndex: number) => {
// the index of the finding in the current page
const newFindingIndex = nextFindingIndex % pageSize;
// if the finding is not in the current page, we need to change the page
const flyoutPageIndex = Math.floor(nextFindingIndex / pageSize);
setUrlQuery({
pageIndex: flyoutPageIndex,
findingIndex: newFindingIndex,
});
},
[pageSize, setUrlQuery]
);
return (
<div data-test-subj={TEST_SUBJECTS.LATEST_FINDINGS_CONTAINER}>
<FindingsSearchBar
dataView={dataView!}
setQuery={(newQuery) => {
setUrlQuery({ ...newQuery, pageIndex: 0 });
}}
loading={findingsGroupByNone.isFetching}
/>
<EuiFlexItem data-test-subj={TEST_SUBJECTS.LATEST_FINDINGS_CONTAINER}>
<FindingsSearchBar dataView={dataView} setQuery={setUrlQuery} loading={isFetching} />
<EuiSpacer size="m" />
{!error && (
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false} style={{ width: 188 }}>
<FindingsGroupBySelector type="default" />
<EuiSpacer size="m" />
</EuiFlexItem>
</EuiFlexGroup>
)}
{error && <ErrorCallout error={error} />}
{!error && (
<>
{findingsGroupByNone.isSuccess && !!findingsGroupByNone.data.page.length && (
{total > 0 && (
<FindingsDistributionBar
{...{
distributionOnClick: handleDistributionClick,
type: i18n.translate('xpack.csp.findings.latestFindings.tableRowTypeLabel', {
defaultMessage: 'Findings',
}),
total: findingsGroupByNone.data.total,
passed: findingsGroupByNone.data.count.passed,
failed: findingsGroupByNone.data.count.failed,
...getFindingsPageSizeInfo({
pageIndex,
pageSize,
currentPageSize: slicedPage.length,
}),
}}
distributionOnClick={handleDistributionClick}
passed={passed}
failed={failed}
/>
)}
<EuiSpacer />
<FindingsTable
onResetFilters={onResetFilters}
onCloseFlyout={onCloseFlyout}
onPaginateFlyout={onPaginateFlyout}
onOpenFlyout={onOpenFlyout}
flyoutFindingIndex={flyoutFindingIndex}
loading={findingsGroupByNone.isFetching}
items={slicedPage}
pagination={pagination}
sorting={{
sort: { field: sort.field, direction: sort.direction },
}}
setTableOptions={setTableOptions}
onAddFilter={(field, value, negate) =>
setUrlQuery({
pageIndex: 0,
filters: getFilters({
filters,
dataView,
field,
value,
negate,
}),
})
}
<CloudSecurityDataTable
data-test-subj={TEST_SUBJECTS.LATEST_FINDINGS_TABLE}
dataView={dataView}
isLoading={isFetching}
defaultColumns={defaultColumns}
rows={rows}
total={total}
flyoutComponent={flyoutComponent}
cloudPostureTable={cloudPostureTable}
loadMore={fetchNextPage}
title={title}
customCellRenderer={customCellRenderer}
/>
</>
)}
{isLastLimitedPage && <LimitedResultsBar />}
</div>
</EuiFlexItem>
);
};

View file

@ -1,133 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import userEvent from '@testing-library/user-event';
import { render, screen, within } from '@testing-library/react';
import * as TEST_SUBJECTS from '../test_subjects';
import { FindingsTable } from './latest_findings_table';
import type { PropsOf } from '@elastic/eui';
import Chance from 'chance';
import { TestProvider } from '../../../test/test_provider';
import { getFindingsFixture } from '../../../test/fixtures/findings_fixture';
import { EMPTY_STATE_TEST_SUBJ } from '../../../components/test_subjects';
const chance = new Chance();
type TableProps = PropsOf<typeof FindingsTable>;
const onAddFilter = jest.fn();
const onOpenFlyout = jest.fn();
const onCloseFlyout = jest.fn();
describe('<FindingsTable />', () => {
const TestComponent = ({ ...overrideProps }) => (
<TestProvider>
<FindingsTable
loading={false}
items={[]}
sorting={{ sort: { field: '@timestamp', direction: 'desc' } }}
pagination={{ pageSize: 10, pageIndex: 0, totalItemCount: 0 }}
setTableOptions={jest.fn()}
onAddFilter={onAddFilter}
onOpenFlyout={onOpenFlyout}
onCloseFlyout={onCloseFlyout}
onPaginateFlyout={jest.fn()}
onResetFilters={jest.fn()}
flyoutFindingIndex={-1}
{...overrideProps}
/>
</TestProvider>
);
const renderWrapper = (overrideProps: Partial<TableProps> = {}) => {
return render(<TestComponent {...overrideProps} />);
};
it('opens/closes the flyout when clicked on expand/close buttons ', async () => {
const props = {
items: [getFindingsFixture()],
};
const { rerender } = renderWrapper(props);
expect(screen.queryByTestId(TEST_SUBJECTS.FINDINGS_FLYOUT)).not.toBeInTheDocument();
expect(screen.queryByTestId(TEST_SUBJECTS.FINDINGS_TABLE_EXPAND_COLUMN)).toBeInTheDocument();
userEvent.click(screen.getByTestId(TEST_SUBJECTS.FINDINGS_TABLE_EXPAND_COLUMN));
expect(onOpenFlyout).toHaveBeenCalled();
rerender(<TestComponent {...props} flyoutFindingIndex={0} />);
userEvent.click(screen.getByTestId('euiFlyoutCloseButton'));
expect(onCloseFlyout).toHaveBeenCalled();
rerender(<TestComponent {...props} />);
expect(screen.queryByTestId(TEST_SUBJECTS.FINDINGS_FLYOUT)).not.toBeInTheDocument();
});
it('renders the zero state when status success and data has a length of zero ', async () => {
renderWrapper({ items: [] });
expect(screen.getByTestId(EMPTY_STATE_TEST_SUBJ)).toBeInTheDocument();
});
it('renders the table with provided items', () => {
const names = chance.unique(chance.sentence, 10);
const data = names.map((name) => {
const fixture = getFindingsFixture();
return { ...fixture, rule: { ...fixture.rule, name } };
});
renderWrapper({ items: data });
data.forEach((item) => {
expect(screen.getAllByText(item.rule.name)[0]).toBeInTheDocument();
});
});
it('adds filter with a cell button click', () => {
const names = chance.unique(chance.sentence, 10);
const data = names.map((name) => {
const fixture = getFindingsFixture();
return { ...fixture, rule: { ...fixture.rule, name } };
});
renderWrapper({ items: data });
const row = data[0];
const columns = [
'result.evaluation',
'resource.id',
'resource.name',
'resource.sub_type',
'rule.name',
];
columns.forEach((field) => {
const cellElement = screen.getByTestId(
TEST_SUBJECTS.getFindingsTableCellTestId(field, row.resource.id)
);
userEvent.hover(cellElement);
const addFilterElement = within(cellElement).getByTestId(
TEST_SUBJECTS.FINDINGS_TABLE_CELL_ADD_FILTER
);
const addNegatedFilterElement = within(cellElement).getByTestId(
TEST_SUBJECTS.FINDINGS_TABLE_CELL_ADD_NEGATED_FILTER
);
// We need to account for values like resource.id (deep.nested.values)
const value = field.split('.').reduce<any>((a, c) => a[c], row);
expect(addFilterElement).toBeVisible();
expect(addNegatedFilterElement).toBeVisible();
userEvent.click(addFilterElement);
expect(onAddFilter).toHaveBeenCalledWith(field, value, false);
userEvent.click(addNegatedFilterElement);
expect(onAddFilter).toHaveBeenCalledWith(field, value, true);
});
});
});

View file

@ -1,120 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import {
EuiBasicTable,
useEuiTheme,
type Pagination,
type EuiBasicTableProps,
type CriteriaWithPagination,
type EuiTableActionsColumnType,
type EuiTableFieldDataColumnType,
} from '@elastic/eui';
import { CspFinding } from '../../../../common/schemas/csp_finding';
import * as TEST_SUBJECTS from '../test_subjects';
import { FindingsRuleFlyout } from '../findings_flyout/findings_flyout';
import {
baseFindingsColumns,
createColumnWithFilters,
getExpandColumn,
type OnAddFilter,
} from '../layout/findings_layout';
import { getSelectedRowStyle } from '../utils/utils';
import { EmptyState } from '../../../components/empty_state';
type TableProps = Required<EuiBasicTableProps<CspFinding>>;
interface Props {
loading: boolean;
items: CspFinding[];
pagination: Pagination & { pageSize: number };
sorting: TableProps['sorting'];
setTableOptions(options: CriteriaWithPagination<CspFinding>): void;
onAddFilter: OnAddFilter;
onPaginateFlyout: (pageIndex: number) => void;
onCloseFlyout: () => void;
onOpenFlyout: (finding: CspFinding) => void;
flyoutFindingIndex: number;
onResetFilters: () => void;
}
const FindingsTableComponent = ({
loading,
items,
pagination,
sorting,
setTableOptions,
onAddFilter,
onOpenFlyout,
flyoutFindingIndex,
onPaginateFlyout,
onCloseFlyout,
onResetFilters,
}: Props) => {
const { euiTheme } = useEuiTheme();
const selectedFinding = items[flyoutFindingIndex];
const getRowProps = (row: CspFinding) => ({
'data-test-subj': TEST_SUBJECTS.getFindingsTableRowTestId(row.resource.id),
style: getSelectedRowStyle(euiTheme, row, selectedFinding),
});
const getCellProps = (row: CspFinding, column: EuiTableFieldDataColumnType<CspFinding>) => ({
'data-test-subj': TEST_SUBJECTS.getFindingsTableCellTestId(column.field, row.resource.id),
});
const columns: [
EuiTableActionsColumnType<CspFinding>,
...Array<EuiTableFieldDataColumnType<CspFinding>>
] = useMemo(
() => [
getExpandColumn<CspFinding>({ onClick: onOpenFlyout }),
createColumnWithFilters(baseFindingsColumns['result.evaluation'], { onAddFilter }),
createColumnWithFilters(baseFindingsColumns['resource.id'], { onAddFilter }),
createColumnWithFilters(baseFindingsColumns['resource.name'], { onAddFilter }),
createColumnWithFilters(baseFindingsColumns['resource.sub_type'], { onAddFilter }),
baseFindingsColumns['rule.benchmark.rule_number'],
createColumnWithFilters(baseFindingsColumns['rule.name'], { onAddFilter }),
createColumnWithFilters(baseFindingsColumns['rule.section'], { onAddFilter }),
baseFindingsColumns['@timestamp'],
],
[onOpenFlyout, onAddFilter]
);
if (!loading && !items.length) {
return <EmptyState onResetFilters={onResetFilters} />;
}
return (
<>
<EuiBasicTable
loading={loading}
data-test-subj={TEST_SUBJECTS.LATEST_FINDINGS_TABLE}
items={items}
columns={columns}
pagination={pagination}
sorting={sorting}
onChange={setTableOptions}
rowProps={getRowProps}
cellProps={getCellProps}
hasActions
/>
{selectedFinding && (
<FindingsRuleFlyout
findings={selectedFinding}
onClose={onCloseFlyout}
findingsCount={pagination.totalItemCount}
flyoutIndex={flyoutFindingIndex + pagination.pageIndex * pagination.pageSize}
onPaginate={onPaginateFlyout}
/>
)}
</>
);
};
export const FindingsTable = React.memo(FindingsTableComponent);

View file

@ -4,28 +4,30 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import { useInfiniteQuery } from '@tanstack/react-query';
import { number } from 'io-ts';
import { lastValueFrom } from 'rxjs';
import type { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/common';
import type { Pagination } from '@elastic/eui';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { buildDataTableRecord } from '@kbn/discover-utils';
import { EsHitRecord } from '@kbn/discover-utils/types';
import { CspFinding } from '../../../../common/schemas/csp_finding';
import { useKibana } from '../../../common/hooks/use_kibana';
import type { Sort, FindingsBaseEsQuery } from '../../../common/types';
import type { FindingsBaseEsQuery } from '../../../common/types';
import { getAggregationCount, getFindingsCountAggQuery } from '../utils/utils';
import { CSP_LATEST_FINDINGS_DATA_VIEW } from '../../../../common/constants';
import { MAX_FINDINGS_TO_LOAD } from '../../../common/constants';
import { showErrorToast } from '../../../common/utils/show_error_toast';
interface UseFindingsOptions extends FindingsBaseEsQuery {
sort: Sort<CspFinding>;
sort: string[][];
enabled: boolean;
}
export interface FindingsGroupByNoneQuery {
pageIndex: Pagination['pageIndex'];
sort: Sort<CspFinding>;
sort: any;
}
type LatestFindingsRequest = IKibanaSearchRequest<estypes.SearchRequest>;
@ -37,15 +39,24 @@ interface FindingsAggs {
count: estypes.AggregationsMultiBucketAggregateBase<estypes.AggregationsStringRareTermsBucketKeys>;
}
export const getFindingsQuery = ({ query, sort }: UseFindingsOptions) => ({
export const getFindingsQuery = ({ query, sort }: UseFindingsOptions, pageParam: any) => ({
index: CSP_LATEST_FINDINGS_DATA_VIEW,
query,
sort: getSortField(sort),
sort: getMultiFieldsSort(sort),
size: MAX_FINDINGS_TO_LOAD,
aggs: getFindingsCountAggQuery(),
ignore_unavailable: false,
...(pageParam ? { search_after: pageParam } : {}),
});
const getMultiFieldsSort = (sort: string[][]) => {
return sort.map(([id, direction]) => {
return {
...getSortField({ field: id, direction }),
};
});
};
/**
* By default, ES will sort keyword fields in case-sensitive format, the
* following fields are required to have a case-insensitive sorting.
@ -60,7 +71,7 @@ const fieldsRequiredSortingByPainlessScript = [
* Generates Painless sorting if the given field is matched or returns default sorting
* This painless script will sort the field in case-insensitive manner
*/
const getSortField = ({ field, direction }: Sort<CspFinding>) => {
const getSortField = ({ field, direction }: { field: string; direction: string }) => {
if (fieldsRequiredSortingByPainlessScript.includes(field)) {
return {
_script: {
@ -81,14 +92,14 @@ export const useLatestFindings = (options: UseFindingsOptions) => {
data,
notifications: { toasts },
} = useKibana().services;
return useQuery(
return useInfiniteQuery(
['csp_findings', { params: options }],
async () => {
async ({ pageParam }) => {
const {
rawResponse: { hits, aggregations },
} = await lastValueFrom(
data.search.search<LatestFindingsRequest, LatestFindingsResponse>({
params: getFindingsQuery(options),
params: getFindingsQuery(options, pageParam),
})
);
if (!aggregations) throw new Error('expected aggregations to be an defined');
@ -96,7 +107,7 @@ export const useLatestFindings = (options: UseFindingsOptions) => {
throw new Error('expected buckets to be an array');
return {
page: hits.hits.map((hit) => hit._source!),
page: hits.hits.map((hit) => buildDataTableRecord(hit as EsHitRecord)),
total: number.is(hits.total) ? hits.total : 0,
count: getAggregationCount(aggregations.count.buckets),
};
@ -105,6 +116,10 @@ export const useLatestFindings = (options: UseFindingsOptions) => {
enabled: options.enabled,
keepPreviousData: true,
onError: (err: Error) => showErrorToast(toasts, err),
getNextPageParam: (lastPage) => {
if (lastPage.page.length === 0) return undefined;
return lastPage.page[lastPage.page.length - 1].raw.sort;
},
}
);
};

View file

@ -16,13 +16,13 @@ import * as TEST_SUBJECTS from '../test_subjects';
import { usePageSlice } from '../../../common/hooks/use_page_slice';
import { FindingsByResourceQuery, useFindingsByResource } from './use_findings_by_resource';
import { FindingsByResourceTable } from './findings_by_resource_table';
import { getFindingsPageSizeInfo, getFilters } from '../utils/utils';
import { getFilters } from '../utils/utils';
import { LimitedResultsBar } from '../layout/findings_layout';
import { FindingsGroupBySelector } from '../layout/findings_group_by_selector';
import { findingsNavigation } from '../../../common/navigation/constants';
import { ResourceFindings } from './resource_findings/resource_findings_container';
import { ErrorCallout } from '../layout/error_callout';
import { FindingsDistributionBar } from '../layout/findings_distribution_bar';
import { CurrentPageOfTotal, FindingsDistributionBar } from '../layout/findings_distribution_bar';
import { LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY } from '../../../common/constants';
import type { FindingsBaseURLQuery, FindingsBaseProps } from '../../../common/types';
import { useCloudPostureTable } from '../../../common/hooks/use_cloud_posture_table';
@ -111,34 +111,42 @@ const LatestFindingsByResource = ({ dataView }: FindingsBaseProps) => {
loading={findingsGroupByResource.isFetching}
/>
<EuiSpacer size="m" />
{!error && (
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false} style={{ width: 188 }}>
<FindingsGroupBySelector type="resource" />
<EuiSpacer size="m" />
</EuiFlexItem>
</EuiFlexGroup>
)}
{error && <ErrorCallout error={error} />}
{!error && (
<>
{findingsGroupByResource.isSuccess && !!findingsGroupByResource.data.page.length && (
<FindingsDistributionBar
{...{
distributionOnClick: handleDistributionClick,
type: i18n.translate('xpack.csp.findings.findingsByResource.tableRowTypeLabel', {
defaultMessage: 'Resources',
}),
total: findingsGroupByResource.data.total,
passed: findingsGroupByResource.data.count.passed,
failed: findingsGroupByResource.data.count.failed,
...getFindingsPageSizeInfo({
pageIndex: urlQuery.pageIndex,
pageSize,
currentPageSize: slicedPage.length,
}),
}}
/>
<>
<FindingsDistributionBar
{...{
distributionOnClick: handleDistributionClick,
type: i18n.translate('xpack.csp.findings.findingsByResource.tableRowTypeLabel', {
defaultMessage: 'Resources',
}),
passed: findingsGroupByResource.data.count.passed,
failed: findingsGroupByResource.data.count.failed,
}}
/>
<EuiSpacer size="l" />
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<CurrentPageOfTotal
pageStart={urlQuery.pageIndex * pageSize + 1}
pageEnd={urlQuery.pageIndex * pageSize + slicedPage.length}
total={findingsGroupByResource.data.total}
type={i18n.translate(
'xpack.csp.findings.findingsByResource.tableRowTypeLabel',
{
defaultMessage: 'Resources',
}
)}
/>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ width: 188, marginLeft: 'auto' }}>
<FindingsGroupBySelector type="resource" />
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
<EuiSpacer />
<FindingsByResourceTable

View file

@ -5,7 +5,13 @@
* 2.0.
*/
import React, { useCallback } from 'react';
import { EuiSpacer, EuiButtonEmpty, type EuiDescriptionListProps } from '@elastic/eui';
import {
EuiSpacer,
EuiButtonEmpty,
type EuiDescriptionListProps,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { Link, useParams } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n-react';
import { generatePath } from 'react-router-dom';
@ -19,11 +25,14 @@ import { LimitedResultsBar, PageTitle, PageTitleText } from '../../layout/findin
import { findingsNavigation } from '../../../../common/navigation/constants';
import { ResourceFindingsQuery, useResourceFindings } from './use_resource_findings';
import { usePageSlice } from '../../../../common/hooks/use_page_slice';
import { getFindingsPageSizeInfo, getFilters } from '../../utils/utils';
import { getFilters } from '../../utils/utils';
import { ResourceFindingsTable } from './resource_findings_table';
import { FindingsSearchBar } from '../../layout/findings_search_bar';
import { ErrorCallout } from '../../layout/error_callout';
import { FindingsDistributionBar } from '../../layout/findings_distribution_bar';
import {
CurrentPageOfTotal,
FindingsDistributionBar,
} from '../../layout/findings_distribution_bar';
import { LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY } from '../../../../common/constants';
import type { FindingsBaseURLQuery, FindingsBaseProps } from '../../../../common/types';
import { useCloudPostureTable } from '../../../../common/hooks/use_cloud_posture_table';
@ -228,22 +237,31 @@ export const ResourceFindings = ({ dataView }: FindingsBaseProps) => {
{!error && (
<>
{resourceFindings.isSuccess && !!resourceFindings.data.page.length && (
<FindingsDistributionBar
{...{
distributionOnClick: handleDistributionClick,
type: i18n.translate('xpack.csp.findings.resourceFindings.tableRowTypeLabel', {
defaultMessage: 'Findings',
}),
total: resourceFindings.data.total,
passed: resourceFindings.data.count.passed,
failed: resourceFindings.data.count.failed,
...getFindingsPageSizeInfo({
pageIndex: urlQuery.pageIndex,
pageSize,
currentPageSize: slicedPage.length,
}),
}}
/>
<>
<FindingsDistributionBar
{...{
distributionOnClick: handleDistributionClick,
type: i18n.translate('xpack.csp.findings.resourceFindings.tableRowTypeLabel', {
defaultMessage: 'Findings',
}),
passed: resourceFindings.data.count.passed,
failed: resourceFindings.data.count.failed,
}}
/>
<EuiSpacer size="l" />
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<CurrentPageOfTotal
pageStart={urlQuery.pageIndex * pageSize + 1}
pageEnd={urlQuery.pageIndex * pageSize + slicedPage.length}
total={resourceFindings.data.total}
type={i18n.translate('xpack.csp.findings.resourceFindings.tableRowTypeLabel', {
defaultMessage: 'Findings',
})}
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
<EuiSpacer />
<ResourceFindingsTable

View file

@ -10,50 +10,65 @@ import {
EuiHealth,
EuiBadge,
EuiSpacer,
EuiTextColor,
EuiFlexGroup,
EuiFlexItem,
useEuiTheme,
EuiTextColor,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import numeral from '@elastic/numeral';
import { RULE_FAILED, RULE_PASSED } from '../../../../common/constants';
import { statusColors } from '../../../common/constants';
import type { Evaluation } from '../../../../common/types';
interface Props {
total: number;
passed: number;
failed: number;
distributionOnClick: (evaluation: Evaluation) => void;
pageStart: number;
pageEnd: number;
type: string;
}
const formatNumber = (value: number) => (value < 1000 ? value : numeral(value).format('0.0a'));
export const CurrentPageOfTotal = ({
pageEnd,
pageStart,
total,
type,
}: {
pageEnd: number;
pageStart: number;
total: number;
type: string;
}) => (
<EuiTextColor color="subdued">
<FormattedMessage
id="xpack.csp.findings.distributionBar.showingPageOfTotalLabel"
defaultMessage="Showing {pageStart}-{pageEnd} of {total} {type}"
values={{
pageStart: <b>{pageStart}</b>,
pageEnd: <b>{pageEnd}</b>,
total: <b>{formatNumber(total)}</b>,
type,
}}
/>
</EuiTextColor>
);
export const FindingsDistributionBar = (props: Props) => (
<div>
<Counters {...props} />
<EuiSpacer size="s" />
{<DistributionBar {...props} />}
<DistributionBar {...props} />
</div>
);
const Counters = (props: Props) => (
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem>
<CurrentPageOfTotal {...props} />
</EuiFlexItem>
<EuiFlexItem
grow={1}
css={css`
align-items: flex-end;
`}
>
<PassedFailedCounters {...props} />
<EuiFlexGroup justifyContent="flexEnd">
<PassedFailedCounters {...props} />
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
@ -86,26 +101,6 @@ const PassedFailedCounters = ({ passed, failed }: Pick<Props, 'passed' | 'failed
);
};
const CurrentPageOfTotal = ({
pageEnd,
pageStart,
total,
type,
}: Pick<Props, 'pageEnd' | 'pageStart' | 'total' | 'type'>) => (
<EuiTextColor color="subdued">
<FormattedMessage
id="xpack.csp.findings.distributionBar.showingPageOfTotalLabel"
defaultMessage="Showing {pageStart}-{pageEnd} of {total} {type}"
values={{
pageStart: <b>{pageStart}</b>,
pageEnd: <b>{pageEnd}</b>,
total: <b>{formatNumber(total)}</b>,
type,
}}
/>
</EuiTextColor>
);
const DistributionBar: React.FC<Omit<Props, 'pageEnd' | 'pageStart'>> = ({
passed,
failed,

View file

@ -67,6 +67,8 @@ export const useStyles = () => {
const groupBySelector = css`
width: 188px;
display: inline-block;
margin-left: 8px;
`;
return {

View file

@ -29,7 +29,6 @@ import type { VulnerabilitiesQueryData } from './types';
import { LATEST_VULNERABILITIES_INDEX_PATTERN } from '../../../common/constants';
import { ErrorCallout } from '../configurations/layout/error_callout';
import { FindingsSearchBar } from '../configurations/layout/findings_search_bar';
import { useFilteredDataView } from '../../common/api/use_filtered_data_view';
import { CVSScoreBadge, SeverityStatusBadge } from '../../components/vulnerability_badges';
import { EmptyState } from '../../components/empty_state';
import { VulnerabilityFindingFlyout } from './vulnerabilities_finding_flyout/vulnerability_finding_flyout';
@ -55,6 +54,7 @@ import { findingsNavigation } from '../../common/navigation/constants';
import { VulnerabilitiesByResource } from './vulnerabilities_by_resource/vulnerabilities_by_resource';
import { ResourceVulnerabilities } from './vulnerabilities_by_resource/resource_vulnerabilities/resource_vulnerabilities';
import { getVulnerabilitiesGridCellActions } from './utils/get_vulnerabilities_grid_cell_actions';
import { useLatestFindingsDataView } from '../../common/api/use_latest_findings_data_view';
const getDefaultQuery = ({ query, filters }: any): any => ({
query,
@ -163,6 +163,11 @@ const VulnerabilitiesDataGrid = ({
});
}, [data?.page, dataView, pageSize, setUrlQuery, urlQuery.filters]);
// Column visibility
const [visibleColumns, setVisibleColumns] = useState(
columns.map(({ id }) => id) // initialize to the full set of columns
);
const flyoutVulnerabilityIndex = urlQuery?.vulnerabilityIndex;
const selectedVulnerabilityIndex = flyoutVulnerabilityIndex
@ -298,10 +303,7 @@ const VulnerabilitiesDataGrid = ({
className={cx({ [styles.gridStyle]: true }, { [styles.highlightStyle]: showHighlight })}
aria-label={VULNERABILITIES}
columns={columns}
columnVisibility={{
visibleColumns: columns.map(({ id }) => id),
setVisibleColumns: () => {},
}}
columnVisibility={{ visibleColumns, setVisibleColumns }}
schemaDetectors={[severitySchemaConfig]}
rowCount={limitedTotalItemCount}
toolbarVisibility={{
@ -311,7 +313,7 @@ const VulnerabilitiesDataGrid = ({
showFullScreenSelector: false,
additionalControls: {
left: {
prepend: (
append: (
<>
<EuiButtonEmpty size="xs" color="text">
{i18n.translate('xpack.csp.vulnerabilities.totalVulnerabilities', {
@ -451,7 +453,10 @@ const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => {
};
export const Vulnerabilities = () => {
const { data, isLoading, error } = useFilteredDataView(LATEST_VULNERABILITIES_INDEX_PATTERN);
const { data, isLoading, error } = useLatestFindingsDataView(
LATEST_VULNERABILITIES_INDEX_PATTERN
);
const getSetupStatus = useCspSetupStatusApi();
if (getSetupStatus?.data?.vuln_mgmt?.status !== 'indexed') return <NoVulnerabilitiesStates />;

View file

@ -6,16 +6,14 @@
*/
import React from 'react';
import Chance from 'chance';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks';
import { Vulnerabilities } from './vulnerabilities';
import {
CSP_LATEST_FINDINGS_DATA_VIEW,
LATEST_VULNERABILITIES_INDEX_DEFAULT_NS,
VULN_MGMT_POLICY_TEMPLATE,
} from '../../../common/constants';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { discoverPluginMock } from '@kbn/discover-plugin/public/mocks';
import { useCspSetupStatusApi } from '../../common/api/use_setup_status_api';
import { useLatestFindingsDataView } from '../../common/api/use_latest_findings_data_view';
import { useSubscriptionStatus } from '../../common/hooks/use_subscription_status';
import { createReactQueryResponse } from '../../test/fixtures/react_query';
import { useCISIntegrationPoliciesLink } from '../../common/navigation/use_navigate_to_cis_integration_policies';
@ -26,11 +24,9 @@ import {
} from '../../components/test_subjects';
import { render } from '@testing-library/react';
import { expectIdsInDoc } from '../../test/utils';
import { fleetMock } from '@kbn/fleet-plugin/public/mocks';
import { licensingMock } from '@kbn/licensing-plugin/public/mocks';
import { TestProvider } from '../../test/test_provider';
import { sharePluginMock } from '@kbn/share-plugin/public/mocks';
import { useLicenseManagementLocatorApi } from '../../common/api/use_license_management_locator_api';
import { createStubDataView } from '@kbn/data-views-plugin/common/stubs';
jest.mock('../../common/api/use_latest_findings_data_view');
jest.mock('../../common/api/use_setup_status_api');
@ -57,21 +53,20 @@ beforeEach(() => {
data: true,
})
);
(useLatestFindingsDataView as jest.Mock).mockReturnValue({
status: 'success',
data: createStubDataView({
spec: {
id: CSP_LATEST_FINDINGS_DATA_VIEW,
},
}),
});
});
const renderVulnerabilitiesPage = () => {
render(
<TestProvider
deps={{
data: dataPluginMock.createStartContract(),
unifiedSearch: unifiedSearchPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
discover: discoverPluginMock.createStartContract(),
fleet: fleetMock.createStartMock(),
licensing: licensingMock.createStart(),
share: sharePluginMock.createStartContract(),
}}
>
<TestProvider>
<Vulnerabilities />
</TestProvider>
);

View file

@ -7,8 +7,8 @@
import React, { lazy, Suspense } from 'react';
import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { SubscriptionTrackingProvider } from '@kbn/subscription-tracking';
import { CspLoadingState } from './components/csp_loading_state';
import type { CspRouterProps } from './application/csp_router';
import type {
@ -68,20 +68,17 @@ export class CspPlugin
Component: LazyCspCustomAssets,
});
const storage = new Storage(localStorage);
// Keep as constant to prevent remounts https://github.com/elastic/kibana/issues/146773
const App = (props: CspRouterProps) => (
<KibanaContextProvider services={{ ...core, ...plugins }}>
<KibanaContextProvider services={{ ...core, ...plugins, storage }}>
<RedirectAppLinks coreStart={core}>
<SubscriptionTrackingProvider
analyticsClient={core.analytics}
navigateToApp={core.application.navigateToApp}
>
<div style={{ width: '100%', height: '100%' }}>
<SetupContext.Provider value={{ isCloudEnabled: this.isCloudEnabled }}>
<CspRouter {...props} />
</SetupContext.Provider>
</div>
</SubscriptionTrackingProvider>
<div style={{ width: '100%', height: '100%' }}>
<SetupContext.Provider value={{ isCloudEnabled: this.isCloudEnabled }}>
<CspRouter {...props} />
</SetupContext.Provider>
</div>
</RedirectAppLinks>
</KibanaContextProvider>
);

View file

@ -21,11 +21,13 @@ import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks
import { discoverPluginMock } from '@kbn/discover-plugin/public/mocks';
import { fleetMock } from '@kbn/fleet-plugin/public/mocks';
import { licensingMock } from '@kbn/licensing-plugin/public/mocks';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { sessionStorageMock } from '@kbn/core-http-server-mocks';
import type { CspClientPluginStartDeps } from '../types';
interface CspAppDeps {
core: CoreStart;
deps: CspClientPluginStartDeps;
deps: Partial<CspClientPluginStartDeps>;
params: AppMountParameters;
}
@ -38,6 +40,8 @@ export const TestProvider: React.FC<Partial<CspAppDeps>> = ({
discover: discoverPluginMock.createStartContract(),
fleet: fleetMock.createStartMock(),
licensing: licensingMock.createStart(),
uiActions: uiActionsPluginMock.createStartContract(),
storage: sessionStorageMock.create(),
},
params = coreMock.createAppMountParameters(),
children,

View file

@ -7,9 +7,16 @@
import type { CloudSetup } from '@kbn/cloud-plugin/public';
import type { LicensingPluginStart } from '@kbn/licensing-plugin/public';
import { DataViewsServicePublic } from '@kbn/data-views-plugin/public';
import type { ComponentType, ReactNode } from 'react';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public';
import type { DataPublicPluginSetup, DataPublicPluginStart } from '@kbn/data-plugin/public';
import { ToastsStart } from '@kbn/core/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import type { ChartsPluginStart } from '@kbn/charts-plugin/public';
import type { DiscoverStart } from '@kbn/discover-plugin/public';
import type { FleetSetup, FleetStart } from '@kbn/fleet-plugin/public';
@ -40,6 +47,7 @@ export interface CspClientPluginSetupDeps {
data: DataPublicPluginSetup;
fleet: FleetSetup;
cloud: CloudSetup;
uiActions: UiActionsSetup;
// optional
usageCollection?: UsageCollectionSetup;
}
@ -47,12 +55,19 @@ export interface CspClientPluginSetupDeps {
export interface CspClientPluginStartDeps {
// required
data: DataPublicPluginStart;
dataViews: DataViewsServicePublic;
dataViewFieldEditor: IndexPatternFieldEditorStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
uiActions: UiActionsStart;
fieldFormats: FieldFormatsStart;
toastNotifications: ToastsStart;
charts: ChartsPluginStart;
discover: DiscoverStart;
fleet: FleetStart;
licensing: LicensingPluginStart;
share: SharePluginStart;
storage: Storage;
// optional
usageCollection?: UsageCollectionStart;
}

View file

@ -50,7 +50,17 @@
"@kbn/share-plugin",
"@kbn/core-http-server",
"@kbn/core-http-browser",
"@kbn/subscription-tracking"
"@kbn/subscription-tracking",
"@kbn/discover-utils",
"@kbn/unified-data-table",
"@kbn/cell-actions",
"@kbn/unified-field-list",
"@kbn/unified-doc-viewer",
"@kbn/kibana-utils-plugin",
"@kbn/ui-actions-plugin",
"@kbn/core-http-server-mocks",
"@kbn/field-formats-plugin",
"@kbn/data-view-field-editor-plugin"
],
"exclude": ["target/**/*"]
}

View file

@ -55,13 +55,7 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider
refresh: true,
}),
]),
add: async <
T extends {
'@timestamp'?: string;
}
>(
findingsMock: T[]
) => {
add: async (findingsMock: Array<Record<string, unknown>>) => {
await Promise.all([
...findingsMock.map((finding) =>
es.index({
@ -124,6 +118,110 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider
},
});
const createDataTableObject = (tableTestSubject: string) => ({
getElement() {
return testSubjects.find(tableTestSubject);
},
async getHeaders() {
const element = await this.getElement();
return await element.findAllByCssSelector('.euiDataGridHeader');
},
async getColumnIndex(columnName: string) {
const element = await this.getElement();
const columnIndex = await (
await element.findByCssSelector(`[data-gridcell-column-id="${columnName}"]`)
).getAttribute('data-gridcell-column-index');
expect(columnIndex).to.be.greaterThan(-1);
return columnIndex;
},
async getColumnHeaderCell(columnName: string) {
const headers = await this.getHeaders();
const headerIndexes = await Promise.all(headers.map((header) => header.getVisibleText()));
const columnIndex = headerIndexes.findIndex((i) => i === columnName);
return headers[columnIndex];
},
async getRowsCount() {
const element = await this.getElement();
const rows = await element.findAllByCssSelector('.euiDataGridRow');
return rows.length;
},
async getFindingsCount(type: 'passed' | 'failed') {
const element = await this.getElement();
const items = await element.findAllByCssSelector(`span[data-test-subj="${type}_finding"]`);
return items.length;
},
async getRowIndexForValue(columnName: string, value: string) {
const values = await this.getColumnValues(columnName);
const rowIndex = values.indexOf(value);
expect(rowIndex).to.be.greaterThan(-1);
return rowIndex;
},
async getFilterElementButton(rowIndex: number, columnIndex: number | string, negated = false) {
const tableElement = await this.getElement();
const button = negated ? 'filterOutButton' : 'filterForButton';
const selector = `[data-gridcell-row-index="${rowIndex}"][data-gridcell-column-index="${columnIndex}"] button[data-test-subj="${button}"]`;
return tableElement.findByCssSelector(selector);
},
async addCellFilter(columnName: string, cellValue: string, negated = false) {
const columnIndex = await this.getColumnIndex(columnName);
const rowIndex = await this.getRowIndexForValue(columnName, cellValue);
const filterElement = await this.getFilterElementButton(rowIndex, columnIndex, negated);
await filterElement.click();
},
async getColumnValues(columnName: string) {
const tableElement = await this.getElement();
const selector = `.euiDataGridRowCell[data-gridcell-column-id="${columnName}"]`;
const columnCells = await tableElement.findAllByCssSelector(selector);
return await Promise.all(columnCells.map((cell) => cell.getVisibleText()));
},
async hasColumnValue(columnName: string, value: string) {
const values = await this.getColumnValues(columnName);
return values.includes(value);
},
async toggleColumnSort(columnName: string, direction: 'asc' | 'desc') {
const currentSorting = await testSubjects.find('dataGridColumnSortingButton');
const currentSortingText = await currentSorting.getVisibleText();
await currentSorting.click();
if (currentSortingText !== 'Sort fields') {
const clearSortButton = await testSubjects.find('dataGridColumnSortingClearButton');
await clearSortButton.click();
}
const selectSortFieldButton = await testSubjects.find('dataGridColumnSortingSelectionButton');
await selectSortFieldButton.click();
const sortField = await testSubjects.find(
`dataGridColumnSortingPopoverColumnSelection-${columnName}`
);
await sortField.click();
const sortDirection = await testSubjects.find(
`euiDataGridColumnSorting-sortColumn-${columnName}-${direction}`
);
await sortDirection.click();
await currentSorting.click();
},
async openFlyoutAt(rowIndex: number) {
const table = await this.getElement();
const flyoutButton = await table.findAllByTestSubject('docTableExpandToggleColumn');
await flyoutButton[rowIndex].click();
},
});
const createTableObject = (tableTestSubject: string) => ({
getElement() {
return testSubjects.find(tableTestSubject);
@ -255,7 +353,7 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider
);
};
const latestFindingsTable = createTableObject('latest_findings_table');
const latestFindingsTable = createDataTableObject('latest_findings_table');
const resourceFindingsTable = createTableObject('resource_findings_table');
const findingsByResourceTable = {
...createTableObject('findings_by_resource_table'),

View file

@ -122,6 +122,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await findings.index.add(data);
await findings.navigateToLatestFindingsPage();
await retry.waitFor(
'Findings table to be loaded',
async () => (await latestFindingsTable.getRowsCount()) === data.length
@ -135,10 +136,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
describe('SearchBar', () => {
it('add filter', async () => {
await filterBar.addFilter({ field: 'rule.name', operation: 'is', value: ruleName1 });
// Filter bar uses the field's customLabel in the DataView
await filterBar.addFilter({ field: 'Rule Name', operation: 'is', value: ruleName1 });
expect(await filterBar.hasFilter('rule.name', ruleName1)).to.be(true);
expect(await latestFindingsTable.hasColumnValue('Rule Name', ruleName1)).to.be(true);
expect(await latestFindingsTable.hasColumnValue('rule.name', ruleName1)).to.be(true);
});
it('remove filter', async () => {
@ -152,8 +154,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await queryBar.setQuery(ruleName1);
await queryBar.submitQuery();
expect(await latestFindingsTable.hasColumnValue('Rule Name', ruleName1)).to.be(true);
expect(await latestFindingsTable.hasColumnValue('Rule Name', ruleName2)).to.be(false);
expect(await latestFindingsTable.hasColumnValue('rule.name', ruleName1)).to.be(true);
expect(await latestFindingsTable.hasColumnValue('rule.name', ruleName2)).to.be(false);
await queryBar.setQuery('');
await queryBar.submitQuery();
@ -162,25 +164,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
});
});
describe('Table Filters', () => {
it('add cell value filter', async () => {
await latestFindingsTable.addCellFilter('Rule Name', ruleName1, false);
expect(await filterBar.hasFilter('rule.name', ruleName1)).to.be(true);
expect(await latestFindingsTable.hasColumnValue('Rule Name', ruleName1)).to.be(true);
});
it('add negated cell value filter', async () => {
await latestFindingsTable.addCellFilter('Rule Name', ruleName1, true);
expect(await filterBar.hasFilter('rule.name', ruleName1, true, false, true)).to.be(true);
expect(await latestFindingsTable.hasColumnValue('Rule Name', ruleName1)).to.be(false);
expect(await latestFindingsTable.hasColumnValue('Rule Name', ruleName2)).to.be(true);
await filterBar.removeFilter('rule.name');
});
});
describe('Table Sort', () => {
type SortingMethod = (a: string, b: string) => number;
type SortDirection = 'asc' | 'desc';
@ -195,14 +178,14 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
it('sorts by a column, should be case sensitive/insensitive depending on the column', async () => {
type TestCase = [string, SortDirection, SortingMethod];
const testCases: TestCase[] = [
['CIS Section', 'asc', sortByAlphabeticalOrder],
['CIS Section', 'desc', sortByAlphabeticalOrder],
['Resource ID', 'asc', compareStringByLexicographicOrder],
['Resource ID', 'desc', compareStringByLexicographicOrder],
['Resource Name', 'asc', sortByAlphabeticalOrder],
['Resource Name', 'desc', sortByAlphabeticalOrder],
['Resource Type', 'asc', sortByAlphabeticalOrder],
['Resource Type', 'desc', sortByAlphabeticalOrder],
['rule.section', 'asc', sortByAlphabeticalOrder],
['rule.section', 'desc', sortByAlphabeticalOrder],
['resource.id', 'asc', compareStringByLexicographicOrder],
['resource.id', 'desc', compareStringByLexicographicOrder],
['resource.name', 'asc', sortByAlphabeticalOrder],
['resource.name', 'desc', sortByAlphabeticalOrder],
['resource.sub_type', 'asc', sortByAlphabeticalOrder],
['resource.sub_type', 'desc', sortByAlphabeticalOrder],
];
for (const [columnName, dir, sortingMethod] of testCases) {
await latestFindingsTable.toggleColumnSort(columnName, dir);