[Cloud Posture] [Findings] Fix Findings empty state results error (#149716)

Issue #144981

## Summary

This PR fixes the resource findings error pop-up when a query filter
yields no results. Suppose a filter query produces zero results then the
aggregations.key.buckets will return an empty array. In the
`assertNonEmptyArray`, we throw an error if the bucket is not an array
or if the bucket's array.length is 0. We shouldn't throw an error if
there are no results or if buckets is an empty array`[]`. To solve this
issue, we need to remove the `arr.length === 0` condition check from
`assertNonEmptyArray`.

The following changes were also introduced:
- Removed `arr.length === 0` check to show an empty state.
- Added unit tests for Resource Findings Table for cases empty state or
table data
- Added a safety check and provided a default value for the first bucket
key.
-  Update translations for Findings page title

## Screenshot
Success state
<img width="1435" alt="image"
src="https://user-images.githubusercontent.com/17135495/215153634-bbb447db-6d2d-4ce3-b188-e3fc445d16d6.png">


Empty State
<img width="1436" alt="image"
src="https://user-images.githubusercontent.com/17135495/215526796-e02ce0d1-2998-4e2a-b3e8-b67d32893bc2.png">

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Lola 2023-02-01 09:26:53 -05:00 committed by GitHub
parent 59366f1317
commit eabf08bbab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 181 additions and 30 deletions

View file

@ -135,6 +135,15 @@ export const ResourceFindings = ({ dataView }: FindingsBaseProps) => {
}),
});
};
function resourceFindingsPageTitleTranslation(resourceName: string | undefined) {
return i18n.translate('xpack.csp.findings.resourceFindings.resourceFindingsPageTitle', {
defaultMessage: '{resourceName} {hyphen} Findings',
values: {
resourceName,
hyphen: resourceFindings.data?.resourceName ? '-' : '',
},
});
}
return (
<div data-test-subj={TEST_SUBJECTS.FINDINGS_CONTAINER}>
@ -150,13 +159,7 @@ export const ResourceFindings = ({ dataView }: FindingsBaseProps) => {
<PageTitleText
title={
<CloudPosturePageTitle
title={i18n.translate(
'xpack.csp.findings.resourceFindings.resourceFindingsPageTitle',
{
defaultMessage: '{resourceName} - Findings',
values: { resourceName: resourceFindings.data?.resourceName },
}
)}
title={resourceFindingsPageTitleTranslation(resourceFindings.data?.resourceName)}
/>
}
/>

View file

@ -0,0 +1,77 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen, within } from '@testing-library/react';
import * as TEST_SUBJECTS from '../../test_subjects';
import { ResourceFindingsTable, ResourceFindingsTableProps } from './resource_findings_table';
import { TestProvider } from '../../../../test/test_provider';
import { capitalize } from 'lodash';
import moment from 'moment';
import { getResourceFindingsTableFixture } from '../../../../test/fixtures/resource_findings_fixture';
describe('<ResourceFindingsTable />', () => {
it('should render no findings empty state when status success and data has a length of zero ', async () => {
const resourceFindingsProps: ResourceFindingsTableProps = {
loading: false,
items: [],
pagination: { pageIndex: 0, pageSize: 10, totalItemCount: 0 },
sorting: {
sort: { field: '@timestamp', direction: 'desc' },
},
setTableOptions: jest.fn(),
onAddFilter: jest.fn(),
};
render(
<TestProvider>
<ResourceFindingsTable {...resourceFindingsProps} />
</TestProvider>
);
expect(
screen.getByTestId(TEST_SUBJECTS.RESOURCES_FINDINGS_TABLE_EMPTY_STATE)
).toBeInTheDocument();
});
it('should render resource finding table content when data items exists', () => {
const data = Array.from({ length: 10 }, getResourceFindingsTableFixture);
const props: ResourceFindingsTableProps = {
loading: false,
items: data,
pagination: { pageIndex: 0, pageSize: 10, totalItemCount: 0 },
sorting: {
sort: { field: 'cluster_id', direction: 'desc' },
},
setTableOptions: jest.fn(),
onAddFilter: jest.fn(),
};
render(
<TestProvider>
<ResourceFindingsTable {...props} />
</TestProvider>
);
data.forEach((item, i) => {
const row = screen.getByTestId(
TEST_SUBJECTS.getResourceFindingsTableRowTestId(item.resource.id)
);
const { evaluation } = item.result;
const evaluationStatusText = capitalize(
item.result.evaluation.slice(0, evaluation.length - 2)
);
expect(row).toBeInTheDocument();
expect(within(row).queryByText(item.rule.name)).toBeInTheDocument();
expect(within(row).queryByText(evaluationStatusText)).toBeInTheDocument();
expect(within(row).queryByText(moment(item['@timestamp']).fromNow())).toBeInTheDocument();
expect(within(row).queryByText(item.rule.section)).toBeInTheDocument();
});
});
});

View file

@ -25,8 +25,9 @@ import {
} from '../../layout/findings_layout';
import { FindingsRuleFlyout } from '../../findings_flyout/findings_flyout';
import { getSelectedRowStyle } from '../../utils/utils';
import * as TEST_SUBJECTS from '../../test_subjects';
interface Props {
export interface ResourceFindingsTableProps {
items: CspFinding[];
loading: boolean;
pagination: Pagination;
@ -42,12 +43,13 @@ const ResourceFindingsTableComponent = ({
sorting,
setTableOptions,
onAddFilter,
}: Props) => {
}: ResourceFindingsTableProps) => {
const { euiTheme } = useEuiTheme();
const [selectedFinding, setSelectedFinding] = useState<CspFinding>();
const getRowProps = (row: CspFinding) => ({
style: getSelectedRowStyle(euiTheme, row, selectedFinding),
'data-test-subj': TEST_SUBJECTS.getResourceFindingsTableRowTestId(row.resource.id),
});
const columns: [
@ -69,6 +71,7 @@ const ResourceFindingsTableComponent = ({
return (
<EuiEmptyPrompt
iconType="logoKibana"
data-test-subj={TEST_SUBJECTS.RESOURCES_FINDINGS_TABLE_EMPTY_STATE}
title={
<h2>
<FormattedMessage
@ -83,6 +86,7 @@ const ResourceFindingsTableComponent = ({
return (
<>
<EuiBasicTable
data-test-subj={TEST_SUBJECTS.RESOURCES_FINDINGS_TABLE}
loading={loading}
items={items}
columns={columns}

View file

@ -34,10 +34,12 @@ type ResourceFindingsResponse = IKibanaSearchResponse<
estypes.SearchResponse<CspFinding, ResourceFindingsResponseAggs>
>;
export type ResourceFindingsResponseAggs = Record<
'count' | 'clusterId' | 'resourceSubType' | 'resourceName',
estypes.AggregationsMultiBucketAggregateBase<estypes.AggregationsStringRareTermsBucketKeys>
>;
export interface ResourceFindingsResponseAggs {
count?: estypes.AggregationsMultiBucketAggregateBase<estypes.AggregationsStringRareTermsBucketKeys>;
clusterId?: estypes.AggregationsMultiBucketAggregateBase<estypes.AggregationsStringRareTermsBucketKeys>;
resourceSubType?: estypes.AggregationsMultiBucketAggregateBase<estypes.AggregationsStringRareTermsBucketKeys>;
resourceName?: estypes.AggregationsMultiBucketAggregateBase<estypes.AggregationsStringRareTermsBucketKeys>;
}
const getResourceFindingsQuery = ({
query,
@ -92,19 +94,18 @@ export const useResourceFindings = (options: UseResourceFindingsOptions) => {
keepPreviousData: true,
select: ({ rawResponse: { hits, aggregations } }: ResourceFindingsResponse) => {
if (!aggregations) throw new Error('expected aggregations to exists');
assertNonEmptyArray(aggregations.count.buckets);
assertNonEmptyArray(aggregations.clusterId.buckets);
assertNonEmptyArray(aggregations.resourceSubType.buckets);
assertNonEmptyArray(aggregations.resourceName.buckets);
assertNonBucketsArray(aggregations.count?.buckets);
assertNonBucketsArray(aggregations.clusterId?.buckets);
assertNonBucketsArray(aggregations.resourceSubType?.buckets);
assertNonBucketsArray(aggregations.resourceName?.buckets);
return {
page: hits.hits.map((hit) => hit._source!),
total: number.is(hits.total) ? hits.total : 0,
count: getAggregationCount(aggregations.count.buckets),
clusterId: getFirstBucketKey(aggregations.clusterId.buckets),
resourceSubType: getFirstBucketKey(aggregations.resourceSubType.buckets),
resourceName: getFirstBucketKey(aggregations.resourceName.buckets),
count: getAggregationCount(aggregations.count?.buckets),
clusterId: getFirstBucketKey(aggregations.clusterId?.buckets),
resourceSubType: getFirstBucketKey(aggregations.resourceSubType?.buckets),
resourceName: getFirstBucketKey(aggregations.resourceName?.buckets),
};
},
onError: (err: Error) => showErrorToast(toasts, err),
@ -112,11 +113,11 @@ export const useResourceFindings = (options: UseResourceFindingsOptions) => {
);
};
function assertNonEmptyArray<T>(arr: unknown): asserts arr is T[] {
if (!Array.isArray(arr) || arr.length === 0) {
throw new Error('expected a non empty array');
function assertNonBucketsArray<T>(arr: unknown): asserts arr is T[] {
if (!Array.isArray(arr)) {
throw new Error('expected buckets to be an array');
}
}
const getFirstBucketKey = (buckets: estypes.AggregationsStringRareTermsBucketKeys[]) =>
buckets[0].key;
const getFirstBucketKey = (buckets: estypes.AggregationsStringRareTermsBucketKeys[]): string =>
buckets[0]?.key;

View file

@ -21,5 +21,10 @@ export const getFindingsTableCellTestId = (columnId: string, rowId: string) =>
export const FINDINGS_TABLE_CELL_ADD_FILTER = 'findings_table_cell_add_filter';
export const FINDINGS_TABLE_CELL_ADD_NEGATED_FILTER = 'findings_table_cell_add_negated_filter';
export const RESOURCES_FINDINGS_TABLE_EMPTY_STATE = 'resource_findings_table_empty_state';
export const RESOURCES_FINDINGS_TABLE = 'resource_findings_table';
export const getResourceFindingsTableRowTestId = (id: string) =>
`resource_findings_table_row_${id}`;
export const DASHBOARD_TABLE_HEADER_SCORE_TEST_ID = 'csp:dashboard-sections-table-header-score';
export const DASHBOARD_TABLE_COLUMN_SCORE_TEST_ID = 'csp:dashboard-sections-table-column-score';

View file

@ -0,0 +1,64 @@
/*
* 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 { EcsEvent } from '@kbn/ecs';
import Chance from 'chance';
import { CspFinding } from '../../../common/schemas/csp_finding';
const chance = new Chance();
export const getResourceFindingsTableFixture = (): CspFinding & { id: string } => ({
cluster_id: chance.guid(),
id: chance.word(),
result: {
expected: {
source: {},
},
evaluation: chance.weighted(['passed', 'failed'], [0.5, 0.5]),
evidence: {
filemode: chance.word(),
},
},
rule: {
audit: chance.paragraph(),
benchmark: {
name: 'CIS Kubernetes',
version: '1.6.0',
id: 'cis_k8s',
},
default_value: chance.sentence(),
description: chance.paragraph(),
id: chance.guid(),
impact: chance.word(),
name: chance.string(),
profile_applicability: chance.sentence(),
rationale: chance.paragraph(),
references: chance.paragraph(),
rego_rule_id: 'cis_X_X_X',
remediation: chance.word(),
section: chance.sentence(),
tags: [],
version: '1.0',
},
agent: {
id: chance.string(),
name: chance.string(),
type: chance.string(),
version: chance.string(),
},
resource: {
name: chance.string(),
type: chance.string(),
raw: {} as any,
sub_type: chance.string(),
id: chance.string(),
},
host: {} as any,
ecs: {} as any,
event: {} as EcsEvent,
'@timestamp': new Date().toISOString(),
});

View file

@ -10055,7 +10055,6 @@
"xpack.csp.findings.distributionBar.showingPageOfTotalLabel": "Affichage de {pageStart}-{pageEnd} sur {total} {type}",
"xpack.csp.findings.findingsTableCell.addFilterButton": "Ajouter un filtre {field}",
"xpack.csp.findings.findingsTableCell.addNegateFilterButton": "Ajouter un filtre {field} négatif",
"xpack.csp.findings.resourceFindings.resourceFindingsPageTitle": "{resourceName} - Résultats",
"xpack.csp.rules.header.rulesCountLabel": "{count, plural, one { règle} other { règles}}",
"xpack.csp.rules.header.totalRulesCount": "Affichage des {rules}",
"xpack.csp.rules.rulePageHeader.pageHeaderTitle": "Règles - {integrationName}",

View file

@ -10044,7 +10044,6 @@
"xpack.csp.findings.distributionBar.showingPageOfTotalLabel": "{total}件中{pageStart}-{pageEnd}件の{type}を表示しています",
"xpack.csp.findings.findingsTableCell.addFilterButton": "{field}フィルターを追加",
"xpack.csp.findings.findingsTableCell.addNegateFilterButton": "{field}否定フィルターを追加",
"xpack.csp.findings.resourceFindings.resourceFindingsPageTitle": "{resourceName} - 調査結果",
"xpack.csp.rules.header.rulesCountLabel": "{count, plural, other {個のルール}}",
"xpack.csp.rules.header.totalRulesCount": "{rules}を表示しています",
"xpack.csp.rules.rulePageHeader.pageHeaderTitle": "ルール - {integrationName}",

View file

@ -10059,7 +10059,6 @@
"xpack.csp.findings.distributionBar.showingPageOfTotalLabel": "正在显示第 {pageStart}-{pageEnd} 个(共 {total} 个){type}",
"xpack.csp.findings.findingsTableCell.addFilterButton": "添加 {field} 筛选",
"xpack.csp.findings.findingsTableCell.addNegateFilterButton": "添加 {field} 作废筛选",
"xpack.csp.findings.resourceFindings.resourceFindingsPageTitle": "{resourceName} - 结果",
"xpack.csp.rules.header.rulesCountLabel": "{count, plural, other { 规则}}",
"xpack.csp.rules.header.totalRulesCount": "正在显示 {rules}",
"xpack.csp.rules.rulePageHeader.pageHeaderTitle": "规则 - {integrationName}",