diff --git a/packages/kbn-securitysolution-grouping/src/components/group_selector/index.test.tsx b/packages/kbn-securitysolution-grouping/src/components/group_selector/index.test.tsx index 2172390f41e85..9f09e3b26ea49 100644 --- a/packages/kbn-securitysolution-grouping/src/components/group_selector/index.test.tsx +++ b/packages/kbn-securitysolution-grouping/src/components/group_selector/index.test.tsx @@ -124,4 +124,52 @@ describe('group selector', () => { ); expect(getByTestId('group-selector-dropdown').title).toEqual('Rule name, Host name'); }); + describe('when maxGroupingLevels is 1', () => { + it('Presents single option selector label when dropdown is clicked', () => { + const { getByTestId } = render( + + ); + fireEvent.click(getByTestId('group-selector-dropdown')); + expect(getByTestId('contextMenuPanelTitle').textContent).toMatch(/select grouping/i); + }); + it('Does not disable any options when maxGroupingLevels is 1 and one option is selected', () => { + const groupSelected = ['kibana.alert.rule.name']; + + const { getByTestId } = render( + + ); + + fireEvent.click(getByTestId('group-selector-dropdown')); + + [...testProps.options, { key: 'custom', label: 'Custom field' }].forEach((o) => { + expect(getByTestId(`panel-${o.key}`)).not.toHaveAttribute('disabled'); + }); + }); + }); + describe('when maxGroupingLevels is greater than 1', () => { + it('Presents select up to "X" groupings when dropdown is clicked', () => { + const { getByTestId } = render( + + ); + fireEvent.click(getByTestId('group-selector-dropdown')); + expect(getByTestId('contextMenuPanelTitle').textContent).toMatch(/select up to 3 groupings/i); + }); + it('Disables non-selected options when maxGroupingLevels is greater than 1 and the selects items reaches the maxGroupingLevels', () => { + const groupSelected = ['kibana.alert.rule.name', 'user.name']; + + const { getByTestId } = render( + + ); + + fireEvent.click(getByTestId('group-selector-dropdown')); + + [...testProps.options, { key: 'custom', label: 'Custom field' }].forEach((o) => { + if (groupSelected.includes(o.key) || o.key === 'none') { + expect(getByTestId(`panel-${o.key}`)).not.toHaveAttribute('disabled'); + } else { + expect(getByTestId(`panel-${o.key}`)).toHaveAttribute('disabled'); + } + }); + }); + }); }); diff --git a/packages/kbn-securitysolution-grouping/src/components/group_selector/index.tsx b/packages/kbn-securitysolution-grouping/src/components/group_selector/index.tsx index 3fa82f7f8af20..ffec5db362066 100644 --- a/packages/kbn-securitysolution-grouping/src/components/group_selector/index.tsx +++ b/packages/kbn-securitysolution-grouping/src/components/group_selector/index.tsx @@ -43,11 +43,21 @@ const GroupSelectorComponent = ({ [groupsSelected] ); - const panels: EuiContextMenuPanelDescriptor[] = useMemo( - () => [ + const panels: EuiContextMenuPanelDescriptor[] = useMemo(() => { + const isOptionDisabled = (key?: string) => { + // Do not disable when maxGroupingLevels is 1 to allow toggling between groups + if (maxGroupingLevels === 1) { + return false; + } + // Disable all non selected options when the maxGroupingLevels is reached + return groupsSelected.length === maxGroupingLevels && (key ? !isGroupSelected(key) : true); + }; + + return [ { id: 'firstPanel', - title: i18n.SELECT_FIELD(maxGroupingLevels), + title: + maxGroupingLevels === 1 ? i18n.SELECT_SINGLE_FIELD : i18n.SELECT_FIELD(maxGroupingLevels), items: [ { 'data-test-subj': 'panel-none', @@ -57,7 +67,7 @@ const GroupSelectorComponent = ({ }, ...options.map((o) => ({ 'data-test-subj': `panel-${o.key}`, - disabled: groupsSelected.length === maxGroupingLevels && !isGroupSelected(o.key), + disabled: isOptionDisabled(o.key), name: o.label, onClick: () => onGroupChange(o.key), icon: isGroupSelected(o.key) ? 'check' : 'empty', @@ -66,7 +76,7 @@ const GroupSelectorComponent = ({ 'data-test-subj': `panel-custom`, name: i18n.CUSTOM_FIELD, icon: 'empty', - disabled: groupsSelected.length === maxGroupingLevels, + disabled: isOptionDisabled(), panel: 'customPanel', hasPanel: true, }, @@ -87,9 +97,8 @@ const GroupSelectorComponent = ({ /> ), }, - ], - [fields, groupsSelected.length, isGroupSelected, maxGroupingLevels, onGroupChange, options] - ); + ]; + }, [fields, groupsSelected.length, isGroupSelected, maxGroupingLevels, onGroupChange, options]); const selectedOptions = useMemo( () => options.filter((groupOption) => isGroupSelected(groupOption.key)), [isGroupSelected, options] diff --git a/packages/kbn-securitysolution-grouping/src/components/translations.ts b/packages/kbn-securitysolution-grouping/src/components/translations.ts index c2e0f13e325a5..de5337db904da 100644 --- a/packages/kbn-securitysolution-grouping/src/components/translations.ts +++ b/packages/kbn-securitysolution-grouping/src/components/translations.ts @@ -32,6 +32,10 @@ export const SELECT_FIELD = (groupingLevelsCount: number) => defaultMessage: 'Select up to {groupingLevelsCount} groupings', }); +export const SELECT_SINGLE_FIELD = i18n.translate('grouping.groupBySingleField', { + defaultMessage: 'Select grouping', +}); + export const NONE = i18n.translate('grouping.noneGroupByOptionName', { defaultMessage: 'None', }); diff --git a/packages/kbn-securitysolution-grouping/src/containers/query/types.ts b/packages/kbn-securitysolution-grouping/src/containers/query/types.ts index b208aef1e840b..69a284ecf7a59 100644 --- a/packages/kbn-securitysolution-grouping/src/containers/query/types.ts +++ b/packages/kbn-securitysolution-grouping/src/containers/query/types.ts @@ -19,11 +19,11 @@ type RunTimeMappings = | Record & { type: RuntimePrimitiveTypes }> | undefined; -interface BoolAgg { +export interface BoolAgg { bool: BoolQuery; } -interface RangeAgg { +export interface RangeAgg { range: { '@timestamp': { gte: string; lte: string } }; } diff --git a/packages/kbn-securitysolution-grouping/src/hooks/use_get_group_selector.test.tsx b/packages/kbn-securitysolution-grouping/src/hooks/use_get_group_selector.test.tsx index 36fe9badac55d..e6221c677b9d5 100644 --- a/packages/kbn-securitysolution-grouping/src/hooks/use_get_group_selector.test.tsx +++ b/packages/kbn-securitysolution-grouping/src/hooks/use_get_group_selector.test.tsx @@ -157,6 +157,34 @@ describe('Group Selector Hooks', () => { }); }); + it('On group change when maxGroupingLevels is 1, remove previously selected group', () => { + const testGroup = { + [groupingId]: { + ...defaultGroup, + options: defaultGroupingOptions, + activeGroups: ['host.name'], + }, + }; + const { result } = renderHook((props) => useGetGroupSelector(props), { + initialProps: { + ...defaultArgs, + maxGroupingLevels: 1, + groupingState: { + groupById: testGroup, + }, + }, + }); + act(() => result.current.props.onGroupChange('user.name')); + + expect(dispatch).toHaveBeenCalledWith({ + payload: { + id: groupingId, + activeGroups: ['user.name'], + }, + type: ActionType.updateActiveGroups, + }); + }); + it('On group change, resets active page, sets active group, and leaves options alone', () => { const testGroup = { [groupingId]: { diff --git a/packages/kbn-securitysolution-grouping/src/hooks/use_get_group_selector.tsx b/packages/kbn-securitysolution-grouping/src/hooks/use_get_group_selector.tsx index d50809c99fb3f..d3e518a48fe59 100644 --- a/packages/kbn-securitysolution-grouping/src/hooks/use_get_group_selector.tsx +++ b/packages/kbn-securitysolution-grouping/src/hooks/use_get_group_selector.tsx @@ -30,6 +30,7 @@ export interface UseGetGroupSelectorArgs { event: string | string[], count?: number | undefined ) => void; + title?: string; } interface UseGetGroupSelectorStateless @@ -84,6 +85,7 @@ export const useGetGroupSelector = ({ onGroupChange, onOptionsChange, tracker, + title, }: UseGetGroupSelectorArgs) => { const { activeGroups: selectedGroups, options } = groupByIdSelector({ groups: groupingState }, groupingId) ?? defaultGroup; @@ -110,20 +112,25 @@ export const useGetGroupSelector = ({ const onChange = useCallback( (groupSelection: string) => { - if (selectedGroups.find((selected) => selected === groupSelection)) { - const groups = selectedGroups.filter((selectedGroup) => selectedGroup !== groupSelection); - if (groups.length === 0) { - setSelectedGroups(['none']); - } else { - setSelectedGroups(groups); + // Simulate a toggle behavior when maxGroupingLevels is 1 + if (maxGroupingLevels === 1) { + setSelectedGroups([groupSelection]); + } else { + if (selectedGroups.find((selected) => selected === groupSelection)) { + const groups = selectedGroups.filter((selectedGroup) => selectedGroup !== groupSelection); + if (groups.length === 0) { + setSelectedGroups(['none']); + } else { + setSelectedGroups(groups); + } + return; } - return; - } - const newSelectedGroups = isNoneGroup([groupSelection]) - ? [groupSelection] - : [...selectedGroups.filter((selectedGroup) => selectedGroup !== 'none'), groupSelection]; - setSelectedGroups(newSelectedGroups); + const newSelectedGroups = isNoneGroup([groupSelection]) + ? [groupSelection] + : [...selectedGroups.filter((selectedGroup) => selectedGroup !== 'none'), groupSelection]; + setSelectedGroups(newSelectedGroups); + } // built-in telemetry: UI-counter tracker?.( @@ -133,7 +140,7 @@ export const useGetGroupSelector = ({ onGroupChange?.({ tableId: groupingId, groupByField: groupSelection }); }, - [groupingId, onGroupChange, selectedGroups, setSelectedGroups, tracker] + [groupingId, maxGroupingLevels, onGroupChange, selectedGroups, setSelectedGroups, tracker] ); useEffect(() => { @@ -184,6 +191,7 @@ export const useGetGroupSelector = ({ fields, maxGroupingLevels, options, + title, }} /> ); diff --git a/packages/kbn-securitysolution-grouping/src/hooks/use_grouping.tsx b/packages/kbn-securitysolution-grouping/src/hooks/use_grouping.tsx index a744fc6c4ff7c..3ff2232acbd72 100644 --- a/packages/kbn-securitysolution-grouping/src/hooks/use_grouping.tsx +++ b/packages/kbn-securitysolution-grouping/src/hooks/use_grouping.tsx @@ -19,7 +19,7 @@ import { Grouping as GroupingComponent } from '../components/grouping'; /** Interface for grouping object where T is the `GroupingAggregation` * @interface GroupingArgs */ -interface Grouping { +export interface UseGrouping { getGrouping: (props: DynamicGroupingProps) => React.ReactElement; groupSelector: React.ReactElement; selectedGroups: string[]; @@ -72,6 +72,7 @@ interface GroupingArgs { event: string | string[], count?: number | undefined ) => void; + title?: string; } /** @@ -85,6 +86,7 @@ interface GroupingArgs { * @param onGroupChange callback executed when selected group is changed, used for tracking * @param onOptionsChange callback executed when grouping options are changed, used for consumer grouping selector * @param tracker telemetry handler + * @param title of the grouping selector component * @returns {@link Grouping} the grouping constructor { getGrouping, groupSelector, pagination, selectedGroups } */ export const useGrouping = ({ @@ -96,7 +98,8 @@ export const useGrouping = ({ onGroupChange, onOptionsChange, tracker, -}: GroupingArgs): Grouping => { + title, +}: GroupingArgs): UseGrouping => { const [groupingState, dispatch] = useReducer(groupsReducerWithStorage, initialState); const { activeGroups: selectedGroups } = useMemo( () => groupByIdSelector({ groups: groupingState }, groupingId) ?? defaultGroup, @@ -125,6 +128,7 @@ export const useGrouping = ({ onGroupChange, onOptionsChange, tracker, + title, }); const getGrouping = useCallback( diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/index.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/index.ts new file mode 100644 index 0000000000000..60a917846dc99 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './use_cloud_posture_data_table'; diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_cloud_posture_data_table.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_cloud_posture_data_table.ts new file mode 100644 index 0000000000000..b7b928a208c0a --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_cloud_posture_data_table.ts @@ -0,0 +1,158 @@ +/* + * 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 { Dispatch, SetStateAction, useCallback } from 'react'; +import { type DataView } from '@kbn/data-views-plugin/common'; +import { BoolQuery, Filter } 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'; +import { LOCAL_STORAGE_DATA_TABLE_COLUMNS_KEY } from '../../constants'; +import { FindingsBaseURLQuery } from '../../types'; + +type URLQuery = FindingsBaseURLQuery & Record; + +type SortOrder = [string, string]; + +export interface CloudPostureDataTableResult { + setUrlQuery: (query: Record) => void; + sort: SortOrder[]; + filters: Filter[]; + query: { bool: BoolQuery }; + queryError?: Error; + pageIndex: number; + urlQuery: URLQuery; + setTableOptions: (options: CriteriaWithPagination) => void; + handleUpdateQuery: (query: URLQuery) => void; + pageSize: number; + setPageSize: Dispatch>; + onChangeItemsPerPage: (newPageSize: number) => void; + onChangePage: (newPageIndex: number) => void; + onSort: (sort: string[][]) => void; + onResetFilters: () => void; + columnsLocalStorageKey: string; + getRowsFromPages: (data: Array<{ page: DataTableRecord[] }> | undefined) => DataTableRecord[]; +} + +/* + Hook for managing common table state and methods for the Cloud Posture DataTable +*/ +export const useCloudPostureDataTable = ({ + defaultQuery = getDefaultQuery, + dataView, + paginationLocalStorageKey, + columnsLocalStorageKey, + nonPersistedFilters, +}: { + defaultQuery?: (params: FindingsBaseURLQuery) => FindingsBaseURLQuery; + dataView: DataView; + paginationLocalStorageKey: string; + columnsLocalStorageKey?: string; + nonPersistedFilters?: Filter[]; +}): CloudPostureDataTableResult => { + const getPersistedDefaultQuery = usePersistedQuery(defaultQuery); + const { urlQuery, setUrlQuery } = useUrlQuery(getPersistedDefaultQuery); + const { pageSize, setPageSize } = usePageSize(paginationLocalStorageKey); + + const onChangeItemsPerPage = useCallback( + (newPageSize) => { + setPageSize(newPageSize); + setUrlQuery({ + pageIndex: 0, + pageSize: newPageSize, + }); + }, + [setPageSize, setUrlQuery] + ); + + const onResetFilters = useCallback(() => { + setUrlQuery({ + pageIndex: 0, + filters: [], + query: { + query: '', + language: 'kuery', + }, + }); + }, [setUrlQuery]); + + const onChangePage = useCallback( + (newPageIndex) => { + setUrlQuery({ + pageIndex: newPageIndex, + }); + }, + [setUrlQuery] + ); + + const onSort = useCallback( + (sort) => { + setUrlQuery({ + sort, + }); + }, + [setUrlQuery] + ); + + const setTableOptions = useCallback( + ({ page, sort }) => { + setPageSize(page.size); + setUrlQuery({ + sort, + pageIndex: page.index, + }); + }, + [setUrlQuery, setPageSize] + ); + + /** + * Page URL query to ES query + */ + const baseEsQuery = useBaseEsQuery({ + dataView, + filters: urlQuery.filters, + query: urlQuery.query, + ...(nonPersistedFilters ? { nonPersistedFilters } : {}), + }); + + const handleUpdateQuery = useCallback( + (query) => { + setUrlQuery({ ...query, pageIndex: 0 }); + }, + [setUrlQuery] + ); + + const getRowsFromPages = (data: Array<{ page: DataTableRecord[] }> | undefined) => + data + ?.map(({ page }: { page: DataTableRecord[] }) => { + return page; + }) + .flat() || []; + + const queryError = baseEsQuery instanceof Error ? baseEsQuery : undefined; + + return { + setUrlQuery, + sort: urlQuery.sort, + filters: urlQuery.filters, + query: baseEsQuery.query, + queryError, + pageIndex: urlQuery.pageIndex, + urlQuery, + setTableOptions, + handleUpdateQuery, + pageSize, + setPageSize, + onChangeItemsPerPage, + onChangePage, + onSort, + onResetFilters, + columnsLocalStorageKey: columnsLocalStorageKey || LOCAL_STORAGE_DATA_TABLE_COLUMNS_KEY, + getRowsFromPages, + }; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/utils.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/utils.ts new file mode 100644 index 0000000000000..e86d2a77589b0 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/utils.ts @@ -0,0 +1,128 @@ +/* + * 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 { useEffect, useCallback, useMemo } from 'react'; +import { buildEsQuery, EsQueryConfig } from '@kbn/es-query'; +import type { EuiBasicTableProps, Pagination } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { type Query } from '@kbn/es-query'; +import { useKibana } from '../use_kibana'; +import type { + FindingsBaseESQueryConfig, + FindingsBaseProps, + FindingsBaseURLQuery, +} from '../../types'; + +const getBaseQuery = ({ + dataView, + query, + filters, + config, +}: FindingsBaseURLQuery & FindingsBaseProps & FindingsBaseESQueryConfig) => { + try { + return { + query: buildEsQuery(dataView, query, filters, config), // will throw for malformed query + }; + } catch (error) { + throw new Error(error); + } +}; + +type TablePagination = NonNullable['pagination']>; + +export const getPaginationTableParams = ( + params: TablePagination & Pick, 'pageIndex' | 'pageSize'>, + pageSizeOptions = [10, 25, 100], + showPerPageOptions = true +): Required => ({ + ...params, + pageSizeOptions, + showPerPageOptions, +}); + +export const getPaginationQuery = ({ + pageIndex, + pageSize, +}: Required>) => ({ + from: pageIndex * pageSize, + size: pageSize, +}); + +export const useBaseEsQuery = ({ + dataView, + filters = [], + query, + nonPersistedFilters, +}: FindingsBaseURLQuery & FindingsBaseProps) => { + const { + notifications: { toasts }, + data: { + query: { filterManager, queryString }, + }, + uiSettings, + } = useKibana().services; + const allowLeadingWildcards = uiSettings.get('query:allowLeadingWildcards'); + const config: EsQueryConfig = useMemo(() => ({ allowLeadingWildcards }), [allowLeadingWildcards]); + const baseEsQuery = useMemo( + () => + getBaseQuery({ + dataView, + filters: filters.concat(nonPersistedFilters ?? []).flat(), + query, + config, + }), + [dataView, filters, nonPersistedFilters, query, config] + ); + + /** + * Sync filters with the URL query + */ + useEffect(() => { + filterManager.setAppFilters(filters); + queryString.setQuery(query); + }, [filters, filterManager, queryString, query]); + + const handleMalformedQueryError = () => { + const error = baseEsQuery instanceof Error ? baseEsQuery : undefined; + if (error) { + toasts.addError(error, { + title: i18n.translate('xpack.csp.findings.search.queryErrorToastMessage', { + defaultMessage: 'Query Error', + }), + toastLifeTimeMs: 1000 * 5, + }); + } + }; + + useEffect(handleMalformedQueryError, [baseEsQuery, toasts]); + + return baseEsQuery; +}; + +export const usePersistedQuery = (getter: ({ filters, query }: FindingsBaseURLQuery) => T) => { + const { + data: { + query: { filterManager, queryString }, + }, + } = useKibana().services; + + return useCallback( + () => + getter({ + filters: filterManager.getAppFilters(), + query: queryString.getQuery() as Query, + }), + [getter, filterManager, queryString] + ); +}; + +export const getDefaultQuery = ({ query, filters }: any): any => ({ + query, + filters, + sort: { field: '@timestamp', direction: 'desc' }, + pageIndex: 0, +}); diff --git a/x-pack/plugins/cloud_security_posture/public/common/types.ts b/x-pack/plugins/cloud_security_posture/public/common/types.ts index 6ebfe7c7a0fa3..a4c26643293fd 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/types.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/types.ts @@ -14,6 +14,10 @@ export type FindingsGroupByKind = 'default' | 'resource'; export interface FindingsBaseURLQuery { query: Query; filters: Filter[]; + /** + * Filters that are part of the query but not persisted in the URL or in the Filter Manager + */ + nonPersistedFilters?: Filter[]; } export interface FindingsBaseProps { diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/additional_controls.tsx b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/additional_controls.tsx index 61a85e9993ecf..b1f79779e65e4 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/additional_controls.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/additional_controls.tsx @@ -10,13 +10,22 @@ import { EuiButtonEmpty, EuiFlexItem } from '@elastic/eui'; import { type DataView } from '@kbn/data-views-plugin/common'; import numeral from '@elastic/numeral'; import { FieldsSelectorModal } from './fields_selector'; -import { FindingsGroupBySelector } from '../../pages/configurations/layout/findings_group_by_selector'; import { useStyles } from './use_styles'; const formatNumber = (value: number) => { return value < 1000 ? value : numeral(value).format('0.0a'); }; +const GroupSelectorWrapper: React.FC = ({ children }) => { + const styles = useStyles(); + + return ( + + {children} + + ); +}; + export const AdditionalControls = ({ total, title, @@ -24,6 +33,7 @@ export const AdditionalControls = ({ columns, onAddColumn, onRemoveColumn, + groupSelectorComponent, }: { total: number; title: string; @@ -31,9 +41,8 @@ export const AdditionalControls = ({ columns: string[]; onAddColumn: (column: string) => void; onRemoveColumn: (column: string) => void; + groupSelectorComponent?: JSX.Element; }) => { - const styles = useStyles(); - const [isFieldSelectorModalVisible, setIsFieldSelectorModalVisible] = useState(false); const closeModal = () => setIsFieldSelectorModalVisible(false); @@ -66,9 +75,9 @@ export const AdditionalControls = ({ })} - - - + {groupSelectorComponent && ( + {groupSelectorComponent} + )} ); }; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx index 9487b405fbba5..50e81a0a0c7ec 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx @@ -69,6 +69,15 @@ interface CloudSecurityDataGridProps { */ loadMore: () => void; 'data-test-subj'?: string; + /** + * This is the component that will be rendered in the group selector. + * This component will receive the current group and a function to change the group. + */ + groupSelectorComponent?: JSX.Element; + /** + * Height override for the data grid. + */ + height?: number; } export const CloudSecurityDataTable = ({ @@ -82,6 +91,8 @@ export const CloudSecurityDataTable = ({ loadMore, title, customCellRenderer, + groupSelectorComponent, + height, ...rest }: CloudSecurityDataGridProps) => { const { @@ -209,6 +220,7 @@ export const CloudSecurityDataTable = ({ columns={currentColumns} onAddColumn={onAddColumn} onRemoveColumn={onRemoveColumn} + groupSelectorComponent={groupSelectorComponent} /> ); @@ -216,7 +228,7 @@ export const CloudSecurityDataTable = ({ // 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 ? 443 : 403}px)`, + height: height ?? `calc(100vh - ${filters?.length > 0 ? 443 : 403}px)`, }; const rowHeightState = 0; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/use_styles.ts b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/use_styles.ts index b3b0fa1b172b1..3919e01af7753 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/use_styles.ts +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/use_styles.ts @@ -23,9 +23,6 @@ export const useStyles = () => { 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}; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/cloud_security_grouping.tsx b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/cloud_security_grouping.tsx new file mode 100644 index 0000000000000..067046ce5457a --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/cloud_security_grouping.tsx @@ -0,0 +1,61 @@ +/* + * 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 { useGrouping } from '@kbn/securitysolution-grouping'; +import { ParsedGroupingAggregation } from '@kbn/securitysolution-grouping/src'; +import { Filter } from '@kbn/es-query'; +import React from 'react'; +import { css } from '@emotion/react'; + +interface CloudSecurityGroupingProps { + data: ParsedGroupingAggregation; + renderChildComponent: (groupFilter: Filter[]) => JSX.Element; + grouping: ReturnType; + activePageIndex: number; + isFetching: boolean; + pageSize: number; + onChangeGroupsItemsPerPage: (size: number) => void; + onChangeGroupsPage: (index: number) => void; + selectedGroup: string; +} + +export const CloudSecurityGrouping = ({ + data, + renderChildComponent, + grouping, + activePageIndex, + isFetching, + pageSize, + onChangeGroupsItemsPerPage, + onChangeGroupsPage, + selectedGroup, +}: CloudSecurityGroupingProps) => { + return ( +
+ {grouping.getGrouping({ + activePage: activePageIndex, + data, + groupingLevel: 0, + inspectButton: undefined, + isLoading: isFetching, + itemsPerPage: pageSize, + onChangeGroupsItemsPerPage, + onChangeGroupsPage, + renderChildComponent, + onGroupClose: () => {}, + selectedGroup, + takeActionItems: () => [], + })} +
+ ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/index.ts b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/index.ts new file mode 100644 index 0000000000000..35a321d06119d --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { useCloudSecurityGrouping } from './use_cloud_security_grouping'; +export { CloudSecurityGrouping } from './cloud_security_grouping'; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/use_cloud_security_grouping.ts b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/use_cloud_security_grouping.ts new file mode 100644 index 0000000000000..d2783af516e35 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/use_cloud_security_grouping.ts @@ -0,0 +1,98 @@ +/* + * 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 { useEffect, useMemo, useState } from 'react'; +import { isNoneGroup, useGrouping } from '@kbn/securitysolution-grouping'; +import * as uuid from 'uuid'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { useUrlQuery } from '../../common/hooks/use_url_query'; +import { + useBaseEsQuery, + usePersistedQuery, +} from '../../common/hooks/use_cloud_posture_data_table/utils'; +import { FindingsBaseURLQuery } from '../../common/types'; + +const DEFAULT_PAGE_SIZE = 10; +const GROUPING_ID = 'cspLatestFindings'; +const MAX_GROUPING_LEVELS = 1; + +/* + Utility hook to handle the grouping logic of the cloud security components +*/ +export const useCloudSecurityGrouping = ({ + dataView, + groupingTitle, + defaultGroupingOptions, + getDefaultQuery, + unit, +}: { + dataView: DataView; + groupingTitle: string; + defaultGroupingOptions: Array<{ label: string; key: string }>; + getDefaultQuery: (params: FindingsBaseURLQuery) => FindingsBaseURLQuery; + unit: (count: number) => string; +}) => { + const getPersistedDefaultQuery = usePersistedQuery(getDefaultQuery); + const { urlQuery, setUrlQuery } = useUrlQuery(getPersistedDefaultQuery); + const [activePageIndex, setActivePageIndex] = useState(0); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + + const { query } = useBaseEsQuery({ + dataView, + filters: urlQuery.filters, + query: urlQuery.query, + }); + + /** + * Reset the active page when the filters or query change + * This is needed because the active page is not automatically reset when the filters or query change + */ + useEffect(() => { + setActivePageIndex(0); + }, [urlQuery.filters, urlQuery.query]); + + const grouping = useGrouping({ + componentProps: { + unit, + }, + defaultGroupingOptions, + fields: dataView.fields, + groupingId: GROUPING_ID, + maxGroupingLevels: MAX_GROUPING_LEVELS, + title: groupingTitle, + onGroupChange: () => { + setActivePageIndex(0); + }, + }); + + const selectedGroup = grouping.selectedGroups[0]; + + // This is recommended by the grouping component to cover an edge case + // where the selectedGroup has multiple values + const uniqueValue = useMemo(() => `${selectedGroup}-${uuid.v4()}`, [selectedGroup]); + + const isNoneSelected = isNoneGroup(grouping.selectedGroups); + + const onChangeGroupsItemsPerPage = (size: number) => { + setActivePageIndex(0); + setPageSize(size); + }; + + const onChangeGroupsPage = (index: number) => setActivePageIndex(index); + + return { + activePageIndex, + grouping, + pageSize, + query, + selectedGroup, + setUrlQuery, + uniqueValue, + isNoneSelected, + onChangeGroupsItemsPerPage, + onChangeGroupsPage, + }; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/components/empty_state.tsx b/x-pack/plugins/cloud_security_posture/public/components/empty_state.tsx index 9c38e635062f7..43f39023c9c32 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/empty_state.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/empty_state.tsx @@ -30,7 +30,9 @@ export const EmptyState = ({ && > .euiEmptyPrompt__main { gap: ${euiTheme.size.xl}; } - margin-top: ${euiTheme.size.xxxl}}; + && { + margin-top: ${euiTheme.size.xxxl}}; + } `} data-test-subj={EMPTY_STATE_TEST_SUBJ} icon={ diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/summary_section.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/summary_section.tsx index 50d5493387466..a0170705b1ec5 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/summary_section.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/summary_section.tsx @@ -21,11 +21,7 @@ import type { PosturePolicyTemplate, } from '../../../../common/types'; import { RisksTable } from '../compliance_charts/risks_table'; -import { - NavFilter, - useNavigateFindings, - useNavigateFindingsByResource, -} from '../../../common/hooks/use_navigate_findings'; +import { NavFilter, useNavigateFindings } from '../../../common/hooks/use_navigate_findings'; import { CSPM_POLICY_TEMPLATE, KSPM_POLICY_TEMPLATE, @@ -55,7 +51,6 @@ export const SummarySection = ({ complianceData: ComplianceDashboardData; }) => { const navToFindings = useNavigateFindings(); - const navToFindingsByResource = useNavigateFindingsByResource(); const cspmIntegrationLink = useCspIntegrationLink(CSPM_POLICY_TEMPLATE); const kspmIntegrationLink = useCspIntegrationLink(KSPM_POLICY_TEMPLATE); @@ -121,7 +116,7 @@ export const SummarySection = ({ { - navToFindingsByResource(getPolicyTemplateQuery(dashboardType)); + navToFindings(getPolicyTemplateQuery(dashboardType)); }} > {i18n.translate( @@ -138,7 +133,7 @@ export const SummarySection = ({ cspmIntegrationLink, dashboardType, kspmIntegrationLink, - navToFindingsByResource, + navToFindings, ] ); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/constants.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/constants.ts new file mode 100644 index 0000000000000..54884856fccf1 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/constants.ts @@ -0,0 +1,71 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { FindingsBaseURLQuery } from '../../../common/types'; +import { CloudSecurityDefaultColumn } from '../../../components/cloud_security_data_table'; + +export const FINDINGS_UNIT = (totalCount: number) => + i18n.translate('xpack.csp.findings.unit', { + values: { totalCount }, + defaultMessage: `{totalCount, plural, =1 {finding} other {findings}}`, + }); + +export const defaultGroupingOptions = [ + { + label: i18n.translate('xpack.csp.findings.latestFindings.groupByResource', { + defaultMessage: 'Resource', + }), + key: 'resource.name', + }, + { + label: i18n.translate('xpack.csp.findings.latestFindings.groupByRuleName', { + defaultMessage: 'Rule name', + }), + key: 'rule.name', + }, + { + label: i18n.translate('xpack.csp.findings.latestFindings.groupByCloudAccount', { + defaultMessage: 'Cloud account', + }), + key: 'cloud.account.name', + }, + { + label: i18n.translate('xpack.csp.findings.latestFindings.groupByKubernetesCluster', { + defaultMessage: 'Kubernetes cluster', + }), + key: 'orchestrator.cluster.name', + }, +]; + +export const groupingTitle = i18n.translate('xpack.csp.findings.latestFindings.groupBy', { + defaultMessage: 'Group findings by', +}); + +export const DEFAULT_TABLE_HEIGHT = 512; + +export const getDefaultQuery = ({ + query, + filters, +}: FindingsBaseURLQuery): FindingsBaseURLQuery & { + sort: string[][]; +} => ({ + query, + filters, + sort: [['@timestamp', 'desc']], +}); + +export const defaultColumns: CloudSecurityDefaultColumn[] = [ + { id: 'result.evaluation', width: 80 }, + { id: 'resource.id' }, + { id: 'resource.name' }, + { id: 'resource.sub_type' }, + { id: 'rule.benchmark.rule_number' }, + { id: 'rule.name' }, + { id: 'rule.section' }, + { id: '@timestamp' }, +]; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx index 9abae7af4a211..613594c66e939 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx @@ -4,180 +4,70 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -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 React, { useCallback } from 'react'; +import { Filter } from '@kbn/es-query'; +import { defaultLoadingRenderer } from '../../../components/cloud_posture_page'; +import { CloudSecurityGrouping } from '../../../components/cloud_security_grouping'; +import type { FindingsBaseProps } from '../../../common/types'; import { FindingsSearchBar } from '../layout/findings_search_bar'; -import * as TEST_SUBJECTS from '../test_subjects'; -import { useLatestFindings } from './use_latest_findings'; -import { FindingsDistributionBar } from '../layout/findings_distribution_bar'; -import { getFilters } from '../utils/utils'; -import { ErrorCallout } from '../layout/error_callout'; -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 { - CloudSecurityDataTable, - CloudSecurityDefaultColumn, -} from '../../../components/cloud_security_data_table'; -import { FindingsRuleFlyout } from '../findings_flyout/findings_flyout'; - -const getDefaultQuery = ({ - query, - filters, -}: { - query: Query; - filters: Filter[]; -}): FindingsBaseURLQuery & { - sort: string[][]; -} => ({ - query, - filters, - sort: [['@timestamp', 'desc']], -}); - -const defaultColumns: CloudSecurityDefaultColumn[] = [ - { id: 'result.evaluation', width: 80 }, - { id: 'resource.id' }, - { id: 'resource.name' }, - { id: 'resource.sub_type' }, - { id: 'rule.benchmark.rule_number' }, - { id: 'rule.name' }, - { id: 'rule.section' }, - { id: '@timestamp' }, -]; - -/** - * Type Guard for checking if the given source is a CspFinding - */ -const isCspFinding = (source: Record | undefined): source is CspFinding => { - return source?.result?.evaluation !== undefined; -}; - -/** - * This Wrapper component renders the children if the given row is a CspFinding - * it uses React's Render Props pattern - */ -const CspFindingRenderer = ({ - row, - children, -}: { - row: DataTableRecord; - children: ({ finding }: { finding: CspFinding }) => JSX.Element; -}) => { - const source = row.raw._source; - const finding = isCspFinding(source) && (source as CspFinding); - if (!finding) return <>; - return children({ finding }); -}; - -/** - * Flyout component for the latest findings table - */ -const flyoutComponent = (row: DataTableRecord, onCloseFlyout: () => void): JSX.Element => { - return ( - - {({ finding }) => } - - ); -}; - -const columnsLocalStorageKey = 'cloudPosture:latestFindings:columns'; - -const title = i18n.translate('xpack.csp.findings.latestFindings.tableRowTypeLabel', { - defaultMessage: 'Findings', -}); - -const customCellRenderer = (rows: DataTableRecord[]) => ({ - 'result.evaluation': ({ rowIndex }: EuiDataGridCellValueElementProps) => ( - - {({ finding }) => } - - ), - '@timestamp': ({ rowIndex }: EuiDataGridCellValueElementProps) => ( - - {({ finding }) => } - - ), -}); +import { DEFAULT_TABLE_HEIGHT } from './constants'; +import { useLatestFindingsGrouping } from './use_latest_findings_grouping'; +import { LatestFindingsTable } from './latest_findings_table'; export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => { - const cloudPostureTable = useCloudPostureTable({ - dataView, - paginationLocalStorageKey: LOCAL_STORAGE_DATA_TABLE_PAGE_SIZE_KEY, - columnsLocalStorageKey, - defaultQuery: getDefaultQuery, - }); - - const { query, sort, queryError, setUrlQuery, filters, getRowsFromPages } = cloudPostureTable; + const renderChildComponent = useCallback( + (groupFilters: Filter[]) => { + return ( + + ); + }, + [dataView] + ); const { - data, - error: fetchError, + isGroupSelect, + groupData, + grouping, isFetching, - fetchNextPage, - } = useLatestFindings({ - query, - sort, - enabled: !queryError, - }); - - const rows = useMemo(() => getRowsFromPages(data?.pages), [data?.pages, getRowsFromPages]); - - const error = fetchError || queryError; - - 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({ - filters: getFilters({ - filters, - dataView, - field: 'result.evaluation', - value: evaluation, - negate: false, - }), - }); - }; + activePageIndex, + pageSize, + selectedGroup, + onChangeGroupsItemsPerPage, + onChangeGroupsPage, + setUrlQuery, + isGroupLoading, + } = useLatestFindingsGrouping({ dataView }); + + if (isGroupSelect) { + return isGroupLoading ? ( + defaultLoadingRenderer() + ) : ( +
+ + +
+ ); + } return ( - + <> - - {error && } - {!error && ( - <> - {total > 0 && ( - - )} - - - - )} - + + ); }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.tsx new file mode 100644 index 0000000000000..a66ce54fa99cc --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.tsx @@ -0,0 +1,148 @@ +/* + * 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 { Filter } from '@kbn/es-query'; +import { DataTableRecord } from '@kbn/discover-utils/types'; +import { i18n } from '@kbn/i18n'; +import { EuiDataGridCellValueElementProps, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import React from 'react'; +import { FindingsBaseProps } from '../../../common/types'; +import * as TEST_SUBJECTS from '../test_subjects'; +import { FindingsDistributionBar } from '../layout/findings_distribution_bar'; +import { ErrorCallout } from '../layout/error_callout'; +import { CloudSecurityDataTable } from '../../../components/cloud_security_data_table'; +import { getDefaultQuery, defaultColumns } from './constants'; +import { useLatestFindingsTable } from './use_latest_findings_table'; +import { TimestampTableCell } from '../../../components/timestamp_table_cell'; +import { CspEvaluationBadge } from '../../../components/csp_evaluation_badge'; +import { CspFinding } from '../../../../common/schemas/csp_finding'; +import { FindingsRuleFlyout } from '../findings_flyout/findings_flyout'; + +type LatestFindingsTableProps = FindingsBaseProps & { + groupSelectorComponent?: JSX.Element; + height?: number; + showDistributionBar?: boolean; + nonPersistedFilters?: Filter[]; +}; + +/** + * Type Guard for checking if the given source is a CspFinding + */ +const isCspFinding = (source: Record | undefined): source is CspFinding => { + return source?.result?.evaluation !== undefined; +}; + +/** + * This Wrapper component renders the children if the given row is a CspFinding + * it uses React's Render Props pattern + */ +const CspFindingRenderer = ({ + row, + children, +}: { + row: DataTableRecord; + children: ({ finding }: { finding: CspFinding }) => JSX.Element; +}) => { + const source = row.raw._source; + const finding = isCspFinding(source) && (source as CspFinding); + if (!finding) return <>; + return children({ finding }); +}; + +/** + * Flyout component for the latest findings table + */ +const flyoutComponent = (row: DataTableRecord, onCloseFlyout: () => void): JSX.Element => { + return ( + + {({ finding }) => } + + ); +}; + +const title = i18n.translate('xpack.csp.findings.latestFindings.tableRowTypeLabel', { + defaultMessage: 'Findings', +}); + +const customCellRenderer = (rows: DataTableRecord[]) => ({ + 'result.evaluation': ({ rowIndex }: EuiDataGridCellValueElementProps) => ( + + {({ finding }) => } + + ), + '@timestamp': ({ rowIndex }: EuiDataGridCellValueElementProps) => ( + + {({ finding }) => } + + ), +}); + +export const LatestFindingsTable = ({ + dataView, + groupSelectorComponent, + height, + showDistributionBar = true, + nonPersistedFilters, +}: LatestFindingsTableProps) => { + const { + cloudPostureTable, + rows, + error, + isFetching, + fetchNextPage, + passed, + failed, + total, + canShowDistributionBar, + onDistributionBarClick, + } = useLatestFindingsTable({ + dataView, + getDefaultQuery, + nonPersistedFilters, + showDistributionBar, + }); + + return ( + + {error ? ( + <> + + + + ) : ( + <> + {canShowDistributionBar && ( + <> + + + + + )} + + + )} + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_grouped_findings.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_grouped_findings.tsx new file mode 100644 index 0000000000000..66b1faf5060c9 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_grouped_findings.tsx @@ -0,0 +1,78 @@ +/* + * 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 { SearchResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { IKibanaSearchResponse } from '@kbn/data-plugin/public'; +import { GroupingAggregation } from '@kbn/securitysolution-grouping'; +import { GenericBuckets, GroupingQuery } from '@kbn/securitysolution-grouping/src'; +import { useQuery } from '@tanstack/react-query'; +import { lastValueFrom } from 'rxjs'; +import { CSP_LATEST_FINDINGS_DATA_VIEW } from '../../../../common/constants'; +import { useKibana } from '../../../common/hooks/use_kibana'; +import { showErrorToast } from '../../../common/utils/show_error_toast'; + +// Elasticsearch returns `null` when a sub-aggregation cannot be computed +type NumberOrNull = number | null; + +export interface FindingsGroupingAggregation { + unitsCount?: { + value?: NumberOrNull; + }; + groupsCount?: { + value?: NumberOrNull; + }; + groupByFields?: { + buckets?: GenericBuckets[]; + }; +} + +export const getGroupedFindingsQuery = (query: GroupingQuery) => ({ + ...query, + index: CSP_LATEST_FINDINGS_DATA_VIEW, + size: 0, +}); + +export const useGroupedFindings = ({ + query, + enabled = true, +}: { + query: GroupingQuery; + enabled: boolean; +}) => { + const { + data, + notifications: { toasts }, + } = useKibana().services; + + return useQuery( + ['csp_grouped_findings', { query }], + async () => { + const { + rawResponse: { aggregations }, + } = await lastValueFrom( + data.search.search< + {}, + IKibanaSearchResponse< + SearchResponse<{}, GroupingAggregation> + > + >({ + params: getGroupedFindingsQuery(query), + }) + ); + + if (!aggregations) throw new Error('Failed to aggregate by, missing resource id'); + + return aggregations; + }, + { + onError: (err: Error) => showErrorToast(toasts, err), + enabled, + // This allows the UI to keep the previous data while the new data is being fetched + keepPreviousData: true, + } + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts index 9ce0292175839..0c0aee860d344 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts @@ -16,7 +16,10 @@ import { CspFinding } from '../../../../common/schemas/csp_finding'; import { useKibana } from '../../../common/hooks/use_kibana'; import type { FindingsBaseEsQuery } from '../../../common/types'; import { getAggregationCount, getFindingsCountAggQuery } from '../utils/utils'; -import { CSP_LATEST_FINDINGS_DATA_VIEW } from '../../../../common/constants'; +import { + CSP_LATEST_FINDINGS_DATA_VIEW, + LATEST_FINDINGS_RETENTION_POLICY, +} from '../../../../common/constants'; import { MAX_FINDINGS_TO_LOAD } from '../../../common/constants'; import { showErrorToast } from '../../../common/utils/show_error_toast'; @@ -41,11 +44,27 @@ interface FindingsAggs { export const getFindingsQuery = ({ query, sort }: UseFindingsOptions, pageParam: any) => ({ index: CSP_LATEST_FINDINGS_DATA_VIEW, - query, sort: getMultiFieldsSort(sort), size: MAX_FINDINGS_TO_LOAD, aggs: getFindingsCountAggQuery(), ignore_unavailable: false, + query: { + ...query, + bool: { + ...query?.bool, + filter: [ + ...(query?.bool?.filter ?? []), + { + range: { + '@timestamp': { + gte: `now-${LATEST_FINDINGS_RETENTION_POLICY}`, + lte: 'now', + }, + }, + }, + ], + }, + }, ...(pageParam ? { search_after: pageParam } : {}), }); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_grouping.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_grouping.tsx new file mode 100644 index 0000000000000..137716967460f --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_grouping.tsx @@ -0,0 +1,74 @@ +/* + * 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 { getGroupingQuery } from '@kbn/securitysolution-grouping'; +import { parseGroupingQuery } from '@kbn/securitysolution-grouping/src'; +import { useMemo } from 'react'; +import { DataView } from '@kbn/data-views-plugin/common'; +import { LATEST_FINDINGS_RETENTION_POLICY } from '../../../../common/constants'; +import { useGroupedFindings } from './use_grouped_findings'; +import { FINDINGS_UNIT, groupingTitle, defaultGroupingOptions, getDefaultQuery } from './constants'; +import { useCloudSecurityGrouping } from '../../../components/cloud_security_grouping'; + +/** + * Utility hook to get the latest findings grouping data + * for the findings page + */ +export const useLatestFindingsGrouping = ({ dataView }: { dataView: DataView }) => { + const { + activePageIndex, + grouping, + pageSize, + query, + selectedGroup, + onChangeGroupsItemsPerPage, + onChangeGroupsPage, + setUrlQuery, + uniqueValue, + isNoneSelected, + } = useCloudSecurityGrouping({ + dataView, + groupingTitle, + defaultGroupingOptions, + getDefaultQuery, + unit: FINDINGS_UNIT, + }); + + const groupingQuery = getGroupingQuery({ + additionalFilters: [query], + groupByField: selectedGroup, + uniqueValue, + from: `now-${LATEST_FINDINGS_RETENTION_POLICY}`, + to: 'now', + pageNumber: activePageIndex * pageSize, + size: pageSize, + sort: [{ _key: { order: 'asc' } }], + }); + + const { data, isFetching } = useGroupedFindings({ + query: groupingQuery, + enabled: !isNoneSelected, + }); + + const groupData = useMemo( + () => parseGroupingQuery(selectedGroup, uniqueValue, data), + [data, selectedGroup, uniqueValue] + ); + + return { + groupData, + grouping, + isFetching, + activePageIndex, + pageSize, + selectedGroup, + onChangeGroupsItemsPerPage, + onChangeGroupsPage, + setUrlQuery, + isGroupSelect: !isNoneSelected, + isGroupLoading: !data, + }; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_table.tsx new file mode 100644 index 0000000000000..2bcc895ddd4b0 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_table.tsx @@ -0,0 +1,86 @@ +/* + * 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 { DataView } from '@kbn/data-views-plugin/common'; +import { Filter } from '@kbn/es-query'; +import { useMemo } from 'react'; +import { FindingsBaseURLQuery } from '../../../common/types'; +import { Evaluation } from '../../../../common/types'; +import { LOCAL_STORAGE_DATA_TABLE_PAGE_SIZE_KEY } from '../../../common/constants'; +import { useCloudPostureDataTable } from '../../../common/hooks/use_cloud_posture_data_table'; +import { getFilters } from '../utils/get_filters'; +import { useLatestFindings } from './use_latest_findings'; + +const columnsLocalStorageKey = 'cloudPosture:latestFindings:columns'; + +export const useLatestFindingsTable = ({ + dataView, + getDefaultQuery, + nonPersistedFilters, + showDistributionBar, +}: { + dataView: DataView; + getDefaultQuery: (params: FindingsBaseURLQuery) => FindingsBaseURLQuery; + nonPersistedFilters?: Filter[]; + showDistributionBar?: boolean; +}) => { + const cloudPostureTable = useCloudPostureDataTable({ + dataView, + paginationLocalStorageKey: LOCAL_STORAGE_DATA_TABLE_PAGE_SIZE_KEY, + columnsLocalStorageKey, + defaultQuery: getDefaultQuery, + nonPersistedFilters, + }); + + const { query, sort, queryError, setUrlQuery, filters, getRowsFromPages } = cloudPostureTable; + + const { + data, + error: fetchError, + isFetching, + fetchNextPage, + } = useLatestFindings({ + query, + sort, + enabled: !queryError, + }); + + const rows = useMemo(() => getRowsFromPages(data?.pages), [data?.pages, getRowsFromPages]); + + const error = fetchError || queryError; + + const passed = data?.pages[0].count.passed || 0; + const failed = data?.pages[0].count.failed || 0; + const total = data?.pages[0].total || 0; + + const onDistributionBarClick = (evaluation: Evaluation) => { + setUrlQuery({ + filters: getFilters({ + filters, + dataView, + field: 'result.evaluation', + value: evaluation, + negate: false, + }), + }); + }; + + const canShowDistributionBar = showDistributionBar && total > 0; + + return { + cloudPostureTable, + rows, + error, + isFetching, + fetchNextPage, + passed, + failed, + total, + canShowDistributionBar, + onDistributionBarClick, + }; +}; diff --git a/x-pack/plugins/cloud_security_posture/tsconfig.json b/x-pack/plugins/cloud_security_posture/tsconfig.json index 0d70ed5c6be6c..48fc1a30594f5 100755 --- a/x-pack/plugins/cloud_security_posture/tsconfig.json +++ b/x-pack/plugins/cloud_security_posture/tsconfig.json @@ -59,7 +59,8 @@ "@kbn/ui-actions-plugin", "@kbn/core-http-server-mocks", "@kbn/field-formats-plugin", - "@kbn/data-view-field-editor-plugin" + "@kbn/data-view-field-editor-plugin", + "@kbn/securitysolution-grouping" ], "exclude": ["target/**/*"] } diff --git a/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts b/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts index 0fd0e463e7087..c711c2300e1be 100644 --- a/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts +++ b/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts @@ -410,6 +410,44 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider }, }); + const groupSelector = (testSubj = 'group-selector-dropdown') => ({ + async getElement() { + return await testSubjects.find(testSubj); + }, + async setValue(value: string) { + const contextMenu = await testSubjects.find('groupByContextMenu'); + const menuItems = await contextMenu.findAllByCssSelector('button.euiContextMenuItem'); + const menuItemsOptions = await Promise.all(menuItems.map((item) => item.getVisibleText())); + const menuItemValueIndex = menuItemsOptions.findIndex((item) => item === value); + await menuItems[menuItemValueIndex].click(); + return await testSubjects.missingOrFail('is-loading-grouping-table', { timeout: 5000 }); + }, + async openDropDown() { + const element = await this.getElement(); + await element.click(); + }, + }); + + const findingsGrouping = async (testSubj = 'cloudSecurityGrouping') => ({ + async getElement() { + return await testSubjects.find(testSubj); + }, + async getGroupCount() { + const element = await this.getElement(); + const groupCount = await element.findByTestSubject('group-count'); + return await groupCount.getVisibleText(); + }, + async getUnitCount() { + const element = await this.getElement(); + const unitCount = await element.findByTestSubject('unit-count'); + return await unitCount.getVisibleText(); + }, + async getRowAtIndex(rowIndex: number) { + const element = await this.getElement(); + const row = await element.findAllByTestSubject('grouping-accordion'); + return await row[rowIndex]; + }, + }); const isLatestFindingsTableThere = async () => { const table = await testSubjects.findAll('docTable'); const trueOrFalse = table.length > 0 ? true : false; @@ -432,6 +470,9 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider misconfigurationsFlyout, toastMessage, detectionRuleApi, + groupSelector, + findingsGrouping, + createDataTableObject, isLatestFindingsTableThere, }; } diff --git a/x-pack/test/cloud_security_posture_functional/pages/findings.ts b/x-pack/test/cloud_security_posture_functional/pages/findings.ts index 4ec0240f735c9..bf45dddebc0b5 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/findings.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/findings.ts @@ -13,7 +13,6 @@ import type { FtrProviderContext } from '../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const queryBar = getService('queryBar'); const filterBar = getService('filterBar'); - const comboBox = getService('comboBox'); const retry = getService('retry'); const pageObjects = getPageObjects(['common', 'findings', 'header']); const chance = new Chance(); @@ -95,24 +94,15 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const ruleName1 = data[0].rule.name; const ruleName2 = data[1].rule.name; - const resourceId1 = data[0].resource.id; - const ruleSection1 = data[0].rule.section; - - const benchMarkName = data[0].rule.benchmark.name; - describe('Findings Page', function () { this.tags(['cloud_security_posture_findings']); let findings: typeof pageObjects.findings; let latestFindingsTable: typeof findings.latestFindingsTable; - let findingsByResourceTable: typeof findings.findingsByResourceTable; - let resourceFindingsTable: typeof findings.resourceFindingsTable; let distributionBar: typeof findings.distributionBar; before(async () => { findings = pageObjects.findings; latestFindingsTable = findings.latestFindingsTable; - findingsByResourceTable = findings.findingsByResourceTable; - resourceFindingsTable = findings.resourceFindingsTable; distributionBar = findings.distributionBar; // Before we start any test we must wait for cloud_security_posture plugin to complete its initialization @@ -219,19 +209,5 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); }); - - describe('GroupBy', () => { - it('groups findings by resource', async () => { - await comboBox.set('findings_group_by_selector', 'Resource'); - expect( - await findingsByResourceTable.hasColumnValue('Applicable Benchmark', benchMarkName) - ).to.be(true); - }); - - it('navigates to resource findings page from resource id link', async () => { - await findingsByResourceTable.clickResourceIdLink(resourceId1, ruleSection1); - expect(await resourceFindingsTable.hasColumnValue('Rule Name', ruleName1)).to.be(true); - }); - }); }); } diff --git a/x-pack/test/cloud_security_posture_functional/pages/findings_alerts.ts b/x-pack/test/cloud_security_posture_functional/pages/findings_alerts.ts index f346fc3d5c285..9b60c77c3ec15 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/findings_alerts.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/findings_alerts.ts @@ -19,6 +19,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { // We need to use a dataset for the tests to run const data = [ { + '@timestamp': new Date().toISOString(), resource: { id: chance.guid(), name: `kubelet`, sub_type: 'lower case sub type' }, result: { evaluation: chance.integer() % 2 === 0 ? 'passed' : 'failed' }, rule: { @@ -40,7 +41,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { cluster_id: 'Upper case cluster id', }, { - '@timestamp': '2023-09-10T14:01:00.000Z', + '@timestamp': new Date(Date.now() - 60 * 60 * 1000).toISOString(), resource: { id: chance.guid(), name: `Pod`, sub_type: 'Upper case sub type' }, result: { evaluation: chance.integer() % 2 === 0 ? 'passed' : 'failed' }, rule: { @@ -62,7 +63,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { cluster_id: 'Another Upper case cluster id', }, { - '@timestamp': '2023-09-10T14:02:00.000Z', + '@timestamp': new Date(Date.now() - 60 * 60 * 1000).toISOString(), resource: { id: chance.guid(), name: `process`, sub_type: 'another lower case type' }, result: { evaluation: 'passed' }, rule: { @@ -84,7 +85,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { cluster_id: 'lower case cluster id', }, { - '@timestamp': '2023-09-10T14:03:00.000Z', + '@timestamp': new Date(Date.now() - 60 * 60 * 1000).toISOString(), resource: { id: chance.guid(), name: `process`, sub_type: 'Upper case type again' }, result: { evaluation: 'failed' }, rule: { diff --git a/x-pack/test/cloud_security_posture_functional/pages/findings_grouping.ts b/x-pack/test/cloud_security_posture_functional/pages/findings_grouping.ts new file mode 100644 index 0000000000000..173630e56837e --- /dev/null +++ b/x-pack/test/cloud_security_posture_functional/pages/findings_grouping.ts @@ -0,0 +1,309 @@ +/* + * 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 expect from '@kbn/expect'; +import Chance from 'chance'; +import { asyncForEach } from '@kbn/std'; +import type { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const queryBar = getService('queryBar'); + const filterBar = getService('filterBar'); + const pageObjects = getPageObjects(['common', 'findings', 'header']); + const chance = new Chance(); + + // We need to use a dataset for the tests to run + // We intentionally make some fields start with a capital letter to test that the query bar is case-insensitive/case-sensitive + const data = [ + { + '@timestamp': new Date().toISOString(), + resource: { id: chance.guid(), name: `kubelet`, sub_type: 'lower case sub type' }, + result: { evaluation: chance.integer() % 2 === 0 ? 'passed' : 'failed' }, + orchestrator: { + cluster: { + id: '1', + name: 'Cluster 1', + }, + }, + rule: { + name: 'Upper case rule name', + section: 'Upper case section', + benchmark: { + id: 'cis_k8s', + posture_type: 'kspm', + name: 'CIS Kubernetes V1.23', + version: 'v1.0.0', + }, + type: 'process', + }, + cluster_id: 'Upper case cluster id', + }, + { + '@timestamp': new Date().toISOString(), + resource: { id: chance.guid(), name: `Pod`, sub_type: 'Upper case sub type' }, + result: { evaluation: chance.integer() % 2 === 0 ? 'passed' : 'failed' }, + cloud: { + account: { + id: '1', + name: 'Account 1', + }, + }, + rule: { + name: 'lower case rule name', + section: 'Another upper case section', + benchmark: { + id: 'cis_k8s', + posture_type: 'kspm', + name: 'CIS Kubernetes V1.23', + version: 'v1.0.0', + }, + type: 'process', + }, + cluster_id: 'Another Upper case cluster id', + }, + { + '@timestamp': new Date().toISOString(), + resource: { id: chance.guid(), name: `process`, sub_type: 'another lower case type' }, + result: { evaluation: 'passed' }, + cloud: { + account: { + id: '1', + name: 'Account 1', + }, + }, + rule: { + name: 'Another upper case rule name', + section: 'lower case section', + benchmark: { + id: 'cis_k8s', + posture_type: 'kspm', + name: 'CIS Kubernetes V1.23', + version: 'v1.0.0', + }, + type: 'process', + }, + cluster_id: 'lower case cluster id', + }, + { + '@timestamp': new Date().toISOString(), + resource: { id: chance.guid(), name: `process`, sub_type: 'Upper case type again' }, + result: { evaluation: 'failed' }, + cloud: { + account: { + id: '2', + name: 'Account 2', + }, + }, + rule: { + name: 'some lower case rule name', + section: 'another lower case section', + benchmark: { + id: 'cis_k8s', + posture_type: 'kspm', + name: 'CIS Kubernetes V1.23', + version: 'v1.0.0', + }, + type: 'process', + }, + cluster_id: 'another lower case cluster id', + }, + ]; + + const ruleName1 = data[0].rule.name; + + describe('Findings Page - Grouping', function () { + this.tags(['cloud_security_posture_findings_grouping']); + let findings: typeof pageObjects.findings; + // let groupSelector: ReturnType; + + before(async () => { + findings = pageObjects.findings; + + // Before we start any test we must wait for cloud_security_posture plugin to complete its initialization + await findings.waitForPluginInitialized(); + + // Prepare mocked findings + await findings.index.remove(); + await findings.index.add(data); + + await findings.navigateToLatestFindingsPage(); + await pageObjects.header.waitUntilLoadingHasFinished(); + }); + + after(async () => { + const groupSelector = await findings.groupSelector(); + await groupSelector.openDropDown(); + await groupSelector.setValue('None'); + await findings.index.remove(); + }); + + describe('Default Grouping', async () => { + it('groups findings by resource and sort case sensitive asc', async () => { + const groupSelector = await findings.groupSelector(); + await groupSelector.openDropDown(); + await groupSelector.setValue('Resource'); + + const grouping = await findings.findingsGrouping(); + + const resourceOrder = ['Pod', 'kubelet', 'process']; + + await asyncForEach(resourceOrder, async (resourceName, index) => { + const groupName = await grouping.getRowAtIndex(index); + expect(await groupName.getVisibleText()).to.be(resourceName); + }); + + const groupCount = await grouping.getGroupCount(); + expect(groupCount).to.be('3 groups'); + + const unitCount = await grouping.getUnitCount(); + expect(unitCount).to.be('4 findings'); + }); + it('groups findings by rule name and sort case sensitive asc', async () => { + const groupSelector = await findings.groupSelector(); + await groupSelector.openDropDown(); + await groupSelector.setValue('Rule name'); + + const grouping = await findings.findingsGrouping(); + + const groupCount = await grouping.getGroupCount(); + expect(groupCount).to.be('4 groups'); + + const unitCount = await grouping.getUnitCount(); + expect(unitCount).to.be('4 findings'); + + const ruleNameOrder = [ + 'Another upper case rule name', + 'Upper case rule name', + 'lower case rule name', + 'some lower case rule name', + ]; + + await asyncForEach(ruleNameOrder, async (resourceName, index) => { + const groupName = await grouping.getRowAtIndex(index); + expect(await groupName.getVisibleText()).to.be(resourceName); + }); + }); + it('groups findings by cloud account and sort case sensitive asc', async () => { + const groupSelector = await findings.groupSelector(); + + await groupSelector.setValue('Cloud account'); + + const grouping = await findings.findingsGrouping(); + + const groupCount = await grouping.getGroupCount(); + expect(groupCount).to.be('3 groups'); + + const unitCount = await grouping.getUnitCount(); + expect(unitCount).to.be('4 findings'); + + const cloudNameOrder = ['Account 1', 'Account 2', '—']; + + await asyncForEach(cloudNameOrder, async (resourceName, index) => { + const groupName = await grouping.getRowAtIndex(index); + expect(await groupName.getVisibleText()).to.be(resourceName); + }); + }); + it('groups findings by Kubernetes cluster and sort case sensitive asc', async () => { + const groupSelector = await findings.groupSelector(); + await groupSelector.setValue('Kubernetes cluster'); + + const grouping = await findings.findingsGrouping(); + + const groupCount = await grouping.getGroupCount(); + expect(groupCount).to.be('2 groups'); + + const unitCount = await grouping.getUnitCount(); + expect(unitCount).to.be('4 findings'); + + const cloudNameOrder = ['Cluster 1', '—']; + + await asyncForEach(cloudNameOrder, async (resourceName, index) => { + const groupName = await grouping.getRowAtIndex(index); + expect(await groupName.getVisibleText()).to.be(resourceName); + }); + }); + }); + describe('SearchBar', () => { + it('add filter', async () => { + const groupSelector = await findings.groupSelector(); + await groupSelector.setValue('Resource'); + + // 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); + + const grouping = await findings.findingsGrouping(); + + const resourceOrder = ['kubelet']; + + await asyncForEach(resourceOrder, async (resourceName, index) => { + const groupName = await grouping.getRowAtIndex(index); + expect(await groupName.getVisibleText()).to.be(resourceName); + }); + + const groupCount = await grouping.getGroupCount(); + expect(groupCount).to.be('1 group'); + + const unitCount = await grouping.getUnitCount(); + expect(unitCount).to.be('1 finding'); + }); + + it('remove filter', async () => { + await filterBar.removeFilter('rule.name'); + + expect(await filterBar.hasFilter('rule.name', ruleName1)).to.be(false); + + const grouping = await findings.findingsGrouping(); + const groupCount = await grouping.getGroupCount(); + expect(groupCount).to.be('3 groups'); + + const unitCount = await grouping.getUnitCount(); + expect(unitCount).to.be('4 findings'); + }); + + it('set search query', async () => { + await queryBar.setQuery(ruleName1); + await queryBar.submitQuery(); + + const grouping = await findings.findingsGrouping(); + + const resourceOrder = ['kubelet']; + + await asyncForEach(resourceOrder, async (resourceName, index) => { + const groupName = await grouping.getRowAtIndex(index); + expect(await groupName.getVisibleText()).to.be(resourceName); + }); + + const groupCount = await grouping.getGroupCount(); + expect(groupCount).to.be('1 group'); + + const unitCount = await grouping.getUnitCount(); + expect(unitCount).to.be('1 finding'); + + await queryBar.setQuery(''); + await queryBar.submitQuery(); + + expect(await grouping.getGroupCount()).to.be('3 groups'); + expect(await grouping.getUnitCount()).to.be('4 findings'); + }); + }); + + describe('Group table', async () => { + it('shows findings table when expanding', async () => { + const grouping = await findings.findingsGrouping(); + const firstRow = await grouping.getRowAtIndex(0); + await (await firstRow.findByCssSelector('button')).click(); + const latestFindingsTable = findings.createDataTableObject('latest_findings_table'); + expect(await latestFindingsTable.getRowsCount()).to.be(1); + expect(await latestFindingsTable.hasColumnValue('rule.name', 'lower case rule name')).to.be( + true + ); + }); + }); + }); +} diff --git a/x-pack/test/cloud_security_posture_functional/pages/index.ts b/x-pack/test/cloud_security_posture_functional/pages/index.ts index f1bb7f8fb7875..9da8cbbeeed54 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/index.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/index.ts @@ -12,6 +12,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { describe('Cloud Security Posture', function () { loadTestFile(require.resolve('./findings_onboarding')); loadTestFile(require.resolve('./findings')); + loadTestFile(require.resolve('./findings_grouping')); loadTestFile(require.resolve('./findings_alerts')); loadTestFile(require.resolve('./compliance_dashboard')); loadTestFile(require.resolve('./vulnerability_dashboard'));