mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
59366f1317
commit
eabf08bbab
9 changed files with 181 additions and 30 deletions
|
@ -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)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
64
x-pack/plugins/cloud_security_posture/public/test/fixtures/resource_findings_fixture.ts
vendored
Normal file
64
x-pack/plugins/cloud_security_posture/public/test/fixtures/resource_findings_fixture.ts
vendored
Normal 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(),
|
||||
});
|
|
@ -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}",
|
||||
|
|
|
@ -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}",
|
||||
|
|
|
@ -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}",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue