mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
# 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\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\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\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:
parent
6518d71387
commit
9a5c2dd363
27 changed files with 911 additions and 745 deletions
|
@ -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"]
|
||||
|
|
|
@ -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);
|
||||
};
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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';
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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;
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -67,6 +67,8 @@ export const useStyles = () => {
|
|||
|
||||
const groupBySelector = css`
|
||||
width: 188px;
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
`;
|
||||
|
||||
return {
|
||||
|
|
|
@ -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 />;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue