From 8d5dfafd8d06cc3096f9b72325032510aa498eab Mon Sep 17 00:00:00 2001 From: Paulo Henrique Date: Wed, 4 Oct 2023 20:57:17 -0700 Subject: [PATCH] [Cloud Security] CloudSecurityDataTable component (#167587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR introduces the Cloud Security DataTable component, meant to replace and consolidate the tables used in the Cloud Security plugin The CloudSecurityDataTable component is a wrapper over the `` component. We made that decision based on the number of features it provides for the users plus support from the most common features such as Flyout, Sort, Filtering, and Pagination to the most advanced features like Virtualization and DataView integration. - **Virtualization**: Thanks to Virtualization, users can fetch more data on the table than the limit of `500`, and also use larger items per page if desired. - **DataView integration**: This option allows users to rename Columns as needed by setting custom labels in the DataView. - **Column control**: Users can move columns and resize it as needed, and that settings is saved on the local storage. - **Row Height control**: Users can modify in the Advanced Settings -> `discover:rowHeight` the height of the row. Below is a Matrix comparing the features between the current Findings table, the Vulnerabilities Data Grid and the new CloudSecurityDataTable. Feature | CloudSecurity DataTable (new >= 8.11) | Findings Table (current <= 8.10) | Vulnerabilities Data Grid (current <= 8.10) -- | -- | -- | -- Tooltip on Cell Hover | ❌ | ✅ | ❌ Column Sorting | ✅ | ✅ | ✅ Multi Column Sorting | ✅ | ❌ | ✅ Column filtering | ✅ | ✅ | ✅ Pagination | ✅ | ✅ | ✅ Rows per page | ✅ | ✅ | ✅ Virtualization (Support to load more data) | ✅ | ❌ | ❌ Custom component on Column Header | ❌ (only custom text) | ✅ | ❌ Additional controls on the left | ✅ | ❌ | ✅ Text truncation | ✅ | ✅ | ✅ Override Style | ✅ (https://github.com/elastic/kibana/pull/166994) | ✅ | ✅ Load more button | ✅ | ❌ | ❌ Resize column | ✅ | ❌ | ✅ Reorder column | ✅ | ❌ | ✅ FullScreen button | ✅ | ❌ | ✅ User-defined row height | ✅ | ❌ | ❌ external pagination control (enables Flyout pagination) | ❌ | ✅ | ✅ user-defined column renaming | ✅ (using DataViews) | ❌ | ❌ ### Regressions There are some regressions such as losing the ability to paginate on the Flyout and the table pagination is no longer controlled by URL params, that's because pagination is controlled by an internal state in the `` component, and we plan to re-enable those features again in the future by contributing to the `` component. ### Screenshots ![image](https://github.com/elastic/kibana/assets/19270322/a0d1f95a-adcc-4e58-9d3e-0adec3df8b3b) ### Videos Basic Features + Virtualization https://github.com/elastic/kibana/assets/19270322/b1a61592-e1ae-4baf-9610-3e24c473c17d https://github.com/elastic/kibana/assets/19270322/d8e6106c-0ca3-4277-b78b-5ca482095ae1 DataView integration https://github.com/elastic/kibana/assets/19270322/0d583243-bb86-45e4-baa5-dc63253da8f6 Row Height Control https://github.com/elastic/kibana/assets/19270322/b1d43609-7c8a-4855-ab2f-624c18663579 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Jordan <51442161+JordanSh@users.noreply.github.com> --- .../cloud_security_posture/kibana.jsonc | 4 +- .../common/api/use_filtered_data_view.ts | 53 ---- .../api/use_latest_findings_data_view.ts | 51 +++- .../public/common/constants.ts | 2 + .../use_cloud_posture_table.ts | 29 +- .../cloud_security_data_table.tsx | 275 ++++++++++++++++++ .../cloud_security_data_table/index.ts | 8 + .../cloud_security_data_table/use_styles.ts | 85 ++++++ .../configurations/configurations.test.tsx | 27 +- .../findings_flyout/findings_flyout.tsx | 32 +- .../latest_findings_container.test.tsx | 87 ------ .../latest_findings_container.tsx | 263 ++++++++--------- .../latest_findings_table.test.tsx | 133 --------- .../latest_findings/latest_findings_table.tsx | 120 -------- .../latest_findings/use_latest_findings.ts | 37 ++- .../findings_by_resource_container.tsx | 60 ++-- .../resource_findings_container.tsx | 56 ++-- .../layout/findings_distribution_bar.tsx | 69 ++--- .../pages/vulnerabilities/hooks/use_styles.ts | 2 + .../pages/vulnerabilities/vulnerabilities.tsx | 19 +- .../vulnerabilities/vulnerabilties.test.tsx | 31 +- .../cloud_security_posture/public/plugin.tsx | 21 +- .../public/test/test_provider.tsx | 6 +- .../cloud_security_posture/public/types.ts | 15 + .../cloud_security_posture/tsconfig.json | 12 +- .../page_objects/findings_page.ts | 114 +++++++- .../pages/findings.ts | 45 +-- 27 files changed, 911 insertions(+), 745 deletions(-) delete mode 100644 x-pack/plugins/cloud_security_posture/public/common/api/use_filtered_data_view.ts create mode 100644 x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx create mode 100644 x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/index.ts create mode 100644 x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/use_styles.ts delete mode 100644 x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.test.tsx delete mode 100644 x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.test.tsx delete mode 100644 x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.tsx diff --git a/x-pack/plugins/cloud_security_posture/kibana.jsonc b/x-pack/plugins/cloud_security_posture/kibana.jsonc index b6dfd66931b1f..9237ed70104ad 100644 --- a/x-pack/plugins/cloud_security_posture/kibana.jsonc +++ b/x-pack/plugins/cloud_security_posture/kibana.jsonc @@ -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"] diff --git a/x-pack/plugins/cloud_security_posture/public/common/api/use_filtered_data_view.ts b/x-pack/plugins/cloud_security_posture/public/common/api/use_filtered_data_view.ts deleted file mode 100644 index cabc449b1e3bd..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/common/api/use_filtered_data_view.ts +++ /dev/null @@ -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().services; - - const findDataView = async (): Promise => { - 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); -}; diff --git a/x-pack/plugins/cloud_security_posture/public/common/api/use_latest_findings_data_view.ts b/x-pack/plugins/cloud_security_posture/public/common/api/use_latest_findings_data_view.ts index 2ab22ff4dd092..86b9692cbfc43 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/api/use_latest_findings_data_view.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/api/use_latest_findings_data_view.ts @@ -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 = { + '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().services; const findDataView = async (): Promise => { - 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; }; diff --git a/x-pack/plugins/cloud_security_posture/public/common/constants.ts b/x-pack/plugins/cloud_security_posture/public/common/constants.ts index bec25a70dbd1e..9f267e07569c2 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/constants.ts @@ -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'; diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/use_cloud_posture_table.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/use_cloud_posture_table.ts index 2d42c2a8303d6..0becb56e6ec22 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/use_cloud_posture_table.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/use_cloud_posture_table.ts @@ -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) => void; + // TODO: Remove any when all finding tables are converted to CloudSecurityDataTable handleUpdateQuery: (query: any) => void; pageSize: number; setPageSize: Dispatch>; 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, }; }; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx new file mode 100644 index 0000000000000..2318a16f2efbb --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx @@ -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( + `${columnsLocalStorageKey}:settings`, + { + columns: defaultColumns.reduce((prev, curr) => { + const newColumn = { [curr.id]: {} }; + return { ...prev, ...newColumn }; + }, {} as UnifiedDataTableSettings['columns']), + } + ); + + const [expandedDoc, setExpandedDoc] = useState(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 ; + } + + return ( + +
0 ? 454 : 414}px)`, + }} + > + + } + gridStyleOverride={gridStyle} + /> +
+
+ ); +}; + +const AdditionalControls = ({ total, title }: { total: number; title: string }) => { + const styles = useStyles(); + return ( + <> + + {`${formatNumber(total)} ${title}`} + + + + + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/index.ts b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/index.ts new file mode 100644 index 0000000000000..b2abf6bd4b8bd --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/index.ts @@ -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'; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/use_styles.ts b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/use_styles.ts new file mode 100644 index 0000000000000..200ea5dbe7330 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/use_styles.ts @@ -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, + }; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.test.tsx index 96e5b2a964f94..d1b35ab617a96 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.test.tsx @@ -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( - + ); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/findings_flyout/findings_flyout.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/findings_flyout/findings_flyout.tsx index 2c59f360850d8..524e893092e53 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/findings_flyout/findings_flyout.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/findings_flyout/findings_flyout.tsx @@ -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> = (props) => ( @@ -166,16 +166,22 @@ export const FindingsRuleFlyout = ({ - - - - + + {onPaginate && ( + + + + )} diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.test.tsx deleted file mode 100644 index 100c42b6520cb..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.test.tsx +++ /dev/null @@ -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('', () => { - 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( - - - - ); - - 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, - }), - }); - }); -}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx index 3c5443d652c08..049010126837c 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx @@ -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 | 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 ( + + {({ finding }) => } + + ); +}; + +const columnsLocalStorageKey = 'cloudSecurityPostureLatestFindingsColumns'; + +const title = i18n.translate('xpack.csp.findings.latestFindings.tableRowTypeLabel', { + defaultMessage: 'Findings', +}); + +const customCellRenderer = (rows: DataTableRecord[]) => ({ + 'result.evaluation': ({ rowIndex }: EuiDataGridCellValueElementProps) => ( + + {({ finding }) => } + + ), + '@timestamp': ({ rowIndex }: EuiDataGridCellValueElementProps) => ( + + {({ finding }) => } + + ), }); 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 ( -
- { - setUrlQuery({ ...newQuery, pageIndex: 0 }); - }} - loading={findingsGroupByNone.isFetching} - /> + + - {!error && ( - - - - - - - )} {error && } {!error && ( <> - {findingsGroupByNone.isSuccess && !!findingsGroupByNone.data.page.length && ( + {total > 0 && ( )} - - setUrlQuery({ - pageIndex: 0, - filters: getFilters({ - filters, - dataView, - field, - value, - negate, - }), - }) - } + )} - {isLastLimitedPage && } -
+ ); }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.test.tsx deleted file mode 100644 index 31b2db9592f63..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.test.tsx +++ /dev/null @@ -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; - -const onAddFilter = jest.fn(); -const onOpenFlyout = jest.fn(); -const onCloseFlyout = jest.fn(); - -describe('', () => { - const TestComponent = ({ ...overrideProps }) => ( - - - - ); - - const renderWrapper = (overrideProps: Partial = {}) => { - return render(); - }; - - 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(); - - userEvent.click(screen.getByTestId('euiFlyoutCloseButton')); - expect(onCloseFlyout).toHaveBeenCalled(); - rerender(); - 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((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); - }); - }); -}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.tsx deleted file mode 100644 index 3ad8deb346998..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.tsx +++ /dev/null @@ -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>; - -interface Props { - loading: boolean; - items: CspFinding[]; - pagination: Pagination & { pageSize: number }; - sorting: TableProps['sorting']; - setTableOptions(options: CriteriaWithPagination): 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) => ({ - 'data-test-subj': TEST_SUBJECTS.getFindingsTableCellTestId(column.field, row.resource.id), - }); - - const columns: [ - EuiTableActionsColumnType, - ...Array> - ] = useMemo( - () => [ - getExpandColumn({ 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 ; - } - - return ( - <> - - {selectedFinding && ( - - )} - - ); -}; - -export const FindingsTable = React.memo(FindingsTableComponent); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts index 00aa0d817e955..9ce0292175839 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts @@ -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; + sort: string[][]; enabled: boolean; } export interface FindingsGroupByNoneQuery { pageIndex: Pagination['pageIndex']; - sort: Sort; + sort: any; } type LatestFindingsRequest = IKibanaSearchRequest; @@ -37,15 +39,24 @@ interface FindingsAggs { count: estypes.AggregationsMultiBucketAggregateBase; } -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) => { +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({ - 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; + }, } ); }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_container.tsx index 1ac0470229282..7f483c3ee0847 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_container.tsx @@ -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} /> - {!error && ( - - - - - - - )} + {error && } {!error && ( <> {findingsGroupByResource.isSuccess && !!findingsGroupByResource.data.page.length && ( - + <> + + + + + + + + + + + )} { {!error && ( <> {resourceFindings.isSuccess && !!resourceFindings.data.page.length && ( - + <> + + + + + + + + )} 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; +}) => ( + + {pageStart}, + pageEnd: {pageEnd}, + total: {formatNumber(total)}, + type, + }} + /> + +); + export const FindingsDistributionBar = (props: Props) => (
- {} +
); const Counters = (props: Props) => ( - + - - - - + + + ); @@ -86,26 +101,6 @@ const PassedFailedCounters = ({ passed, failed }: Pick) => ( - - {pageStart}, - pageEnd: {pageEnd}, - total: {formatNumber(total)}, - type, - }} - /> - -); - const DistributionBar: React.FC> = ({ passed, failed, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_styles.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_styles.ts index e18f501cbeb9c..245138775e5b9 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_styles.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_styles.ts @@ -67,6 +67,8 @@ export const useStyles = () => { const groupBySelector = css` width: 188px; + display: inline-block; + margin-left: 8px; `; return { diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx index a9b8fdaa2f190..6c1aa59cfab4c 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx @@ -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: ( <> {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 ; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilties.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilties.test.tsx index 6eab4ba03f682..24c12405c1436 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilties.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilties.test.tsx @@ -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( - + ); diff --git a/x-pack/plugins/cloud_security_posture/public/plugin.tsx b/x-pack/plugins/cloud_security_posture/public/plugin.tsx index 32e5ee577e40e..f215841b30cea 100755 --- a/x-pack/plugins/cloud_security_posture/public/plugin.tsx +++ b/x-pack/plugins/cloud_security_posture/public/plugin.tsx @@ -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) => ( - + - -
- - - -
-
+
+ + + +
); diff --git a/x-pack/plugins/cloud_security_posture/public/test/test_provider.tsx b/x-pack/plugins/cloud_security_posture/public/test/test_provider.tsx index 57fc2935e5708..3f89c934e5dd4 100755 --- a/x-pack/plugins/cloud_security_posture/public/test/test_provider.tsx +++ b/x-pack/plugins/cloud_security_posture/public/test/test_provider.tsx @@ -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; params: AppMountParameters; } @@ -38,6 +40,8 @@ export const TestProvider: React.FC> = ({ discover: discoverPluginMock.createStartContract(), fleet: fleetMock.createStartMock(), licensing: licensingMock.createStart(), + uiActions: uiActionsPluginMock.createStartContract(), + storage: sessionStorageMock.create(), }, params = coreMock.createAppMountParameters(), children, diff --git a/x-pack/plugins/cloud_security_posture/public/types.ts b/x-pack/plugins/cloud_security_posture/public/types.ts index c888496a0b157..6766067df67e0 100755 --- a/x-pack/plugins/cloud_security_posture/public/types.ts +++ b/x-pack/plugins/cloud_security_posture/public/types.ts @@ -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; } diff --git a/x-pack/plugins/cloud_security_posture/tsconfig.json b/x-pack/plugins/cloud_security_posture/tsconfig.json index 307394e41d84b..113ddcb92202a 100755 --- a/x-pack/plugins/cloud_security_posture/tsconfig.json +++ b/x-pack/plugins/cloud_security_posture/tsconfig.json @@ -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/**/*"] } diff --git a/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts b/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts index bd3a43951bf25..49f4ab0a6d12f 100644 --- a/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts +++ b/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts @@ -55,13 +55,7 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider refresh: true, }), ]), - add: async < - T extends { - '@timestamp'?: string; - } - >( - findingsMock: T[] - ) => { + add: async (findingsMock: Array>) => { 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'), diff --git a/x-pack/test/cloud_security_posture_functional/pages/findings.ts b/x-pack/test/cloud_security_posture_functional/pages/findings.ts index 5caedd4a6e7f2..2dbee8496998a 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/findings.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/findings.ts @@ -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);