;
+ 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'));